Compare commits
485 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82c3e96759 | |||
| 6f84af130d | |||
| ef0a10c262 | |||
| 31737b7820 | |||
| f0ad9e6ef2 | |||
| 6cad37cb6c | |||
| b096a03dfd | |||
| 5798b0a2ee | |||
| d946d3b79f | |||
| be4a9f34ec | |||
| a00b1647d4 | |||
| 0c7a35bd57 | |||
| e95809ba61 | |||
| 6dae483607 | |||
| 0c5be6ab82 | |||
| b8c03dfa73 | |||
| b2b9ab4344 | |||
| 6fedd0e993 | |||
| 2d9454ea3f | |||
| 49dd26ef0a | |||
| 00d44256b4 | |||
| 894853074d | |||
| 891eff01ea | |||
| 16384d423e | |||
| c4ac0c23ad | |||
| f3df053ab2 | |||
| e2813d0290 | |||
| 2efa542338 | |||
| debe79df87 | |||
| 0189c38f4c | |||
| 45f1005392 | |||
| 314d629bc8 | |||
| 7f01e650b9 | |||
| 47f3d36517 | |||
| 46266b28c5 | |||
| c610ad6828 | |||
| 3d541bcb24 | |||
| f287fccf4d | |||
| 7ff04c6e17 | |||
| 88266587e4 | |||
| ad2aad900e | |||
| 54e49eca92 | |||
| 41b5346f53 | |||
| 01d28e7b96 | |||
| bf15a8f8ff | |||
| 2cac30fa48 | |||
| 0a3cb0ffe7 | |||
| 46a2283140 | |||
| dcff922c56 | |||
| e310d7f390 | |||
| 83009472b7 | |||
| c09cfdfcbf | |||
| 992b5a2c0d | |||
| 1f2913422e | |||
| aa6d87462b | |||
| a03eabc636 | |||
| 5c35a1aff8 | |||
| 6f690816d7 | |||
| e1f01be1a9 | |||
| 2a0e450c53 | |||
| 1eae4c88a6 | |||
| de78e66da1 | |||
| 2ff323b920 | |||
| 0ffafeb247 | |||
| 1a3a125b82 | |||
| 77c160a64e | |||
| 473d512e1c | |||
| 670eda8d91 | |||
| d9b77d5017 | |||
| 104251f800 | |||
| 99398fde6b | |||
| 01c0bb8a32 | |||
| 4d6a53f6e7 | |||
| 2656db9579 | |||
| 2ede22282d | |||
| 48905790f0 | |||
| 3cf0773fd6 | |||
| fc068866d9 | |||
| dacf707165 | |||
| 76f9da07a9 | |||
| 38dd78fdab | |||
| 3b5b0c1a73 | |||
| 5ab496b399 | |||
| 64a11706fd | |||
| 969f7fb615 | |||
| 3c28483faf | |||
| 8a2df44865 | |||
| 23ccbcbeae | |||
| 696ffefc1c | |||
| 1a33542f20 | |||
| caa1a2a96e | |||
| 1229f111e8 | |||
| fa893e8713 | |||
| f586175be2 | |||
| 55b4f994dc | |||
| 2d0ec0bca8 | |||
| fc32dbe8ab | |||
| 55ec926fdc | |||
| b4f916addb | |||
| a51f04c841 | |||
| db2ed26e65 | |||
| 509470b20b | |||
| 76254db28c | |||
| f176f424b5 | |||
| a5b94284cb | |||
| b24c563cc9 | |||
| d94909eb91 | |||
| 19590cef8c | |||
| d353b1ee36 | |||
| d07eb89f66 | |||
| 398fefe2fd | |||
| 5e33f94cce | |||
| e7cdc41648 | |||
| e3c15979b8 | |||
| 68ab5bdd44 | |||
| 1fe19fe5f1 | |||
| 17ef84e867 | |||
| 8d4a5b7a04 | |||
| 9fed55d5c0 | |||
| d79d5393be | |||
| 3cfe653b18 | |||
| 882a4bfb5c | |||
| d792b7ff0c | |||
| 68ffffe2af | |||
| 0fb82306bb | |||
| b170894228 | |||
| 082fa0798c | |||
| d1ee2ef3f4 | |||
| 7f9b59a36d | |||
| 79047e37b5 | |||
| 3d5f9346c6 | |||
| 93c82a9cee | |||
| 384b8824c6 | |||
| e01791ae68 | |||
| e42d6e7596 | |||
| 36658fa8ca | |||
| 5645516845 | |||
| 4ce8c6b4ea | |||
| 01056afe74 | |||
| 3cc39cfa8f | |||
| 0956757445 | |||
| 9c75d0254e | |||
| c847b4a274 | |||
| c93ae27b64 | |||
| 0e28958ede | |||
| 46bb7c31c2 | |||
| 04af4a93a8 | |||
| 3b99c5b6bc | |||
| 1b47876a6c | |||
| 48ff2b2109 | |||
| 0c4857d6e0 | |||
| 9f3e4b9d31 | |||
| 3834ba4c1c | |||
| a8a41e9bad | |||
| 8c927b0a1b | |||
| 21e57eaadc | |||
| fadd3a01cd | |||
| 95097c4d3f | |||
| e71b075d94 | |||
| 1ecc8be8d1 | |||
| 361a58f8cd | |||
| 51ac178281 | |||
| b46da78e6c | |||
| 57a54e8959 | |||
| 1c8625f828 | |||
| 66b19f184c | |||
| 4694e67e1c | |||
| e2e2ac8b56 | |||
| 415eeaac56 | |||
| 4c8bb93952 | |||
| 561fdcd881 | |||
| 0d096acfa8 | |||
| 3db14d29ef | |||
| dfff3c327f | |||
| 4ab3b163f6 | |||
| 60910c2b8b | |||
| 4e0151be1b | |||
| 36d958f31f | |||
| e482a293c9 | |||
| 04a4bf8aba | |||
| b3082f27e3 | |||
| f30d7dd7af | |||
| ecd5b6c786 | |||
| 1f7419f33d | |||
| 171f489e3d | |||
| e808a168cb | |||
| 1f89a323d5 | |||
| 329eca3db6 | |||
| 42b47be564 | |||
| 79ac068bc4 | |||
| ee7260b435 | |||
| 9498a56f98 | |||
| 8ea6df020b | |||
| b304d6c9a2 | |||
| 557c15cbe0 | |||
| 524523b8c6 | |||
| e858130375 | |||
| f0e2228700 | |||
| c5aef3c939 | |||
| f401a76227 | |||
| 71133cdc24 | |||
| 7bd9213ec5 | |||
| 5ca1eb98a8 | |||
| 65a6cdf505 | |||
| 5ab21a0fac | |||
| 0a5d43e12b | |||
| 92b32dd924 | |||
| 0d96174f75 | |||
| 27959a0afe | |||
| 6acae6d20f | |||
| 9a8b3b53fc | |||
| eae734afca | |||
| 1a42a71852 | |||
| 3976ce78c3 | |||
| 9c4d9f060e | |||
| dfa38b6e0e | |||
| c862a01a0f | |||
| 9dacc01a67 | |||
| e76248a1c9 | |||
| d30f8eb0db | |||
| ef654ad3fc | |||
| c7d914f786 | |||
| 729aa3850d | |||
| 6b9a0867ac | |||
| f6c73c4f82 | |||
| b24e4e097b | |||
| 6fa3f4fa82 | |||
| e01167f679 | |||
| d4176836a5 | |||
| 375d11c199 | |||
| ef9d98ea04 | |||
| 6d29e9a853 | |||
| fea6ae9f0a | |||
| 3f69fe6fc1 | |||
| 5d303287c0 | |||
| ffecdc4796 | |||
| b32a7c12e7 | |||
| 64eade2589 | |||
| f1dbc10e4d | |||
| 1289ef81b2 | |||
| 81781e393d | |||
| bd403e4617 | |||
| 7c6d8a1b65 | |||
| 31e1843fe1 | |||
| 1ab8230191 | |||
| 070df8982b | |||
| 07fd04d27e | |||
| 8d4a302730 | |||
| 2fbaf09e88 | |||
| 36082bd2e3 | |||
| 99179ad245 | |||
| 0fccd3f1a4 | |||
| 3bc1e66acf | |||
| dcf115e572 | |||
| 75f73b0dff | |||
| 30a6f6607a | |||
| ef873bda3b | |||
| a2006c2287 | |||
| 3243ecba4a | |||
| 0552c0a0b0 | |||
| 8de7b473a8 | |||
| 130aa26f27 | |||
| 3f6a7af83e | |||
| b8083203e9 | |||
| 290fc0fb99 | |||
| de7a945470 | |||
| 7d9dbe702b | |||
| 1819fa276c | |||
| 31a4d12ceb | |||
| 9fedffe570 | |||
| 8903af5d7f | |||
| 1c7738e276 | |||
| 234c6037c0 | |||
| 055562b06a | |||
| f057f0ba86 | |||
| 500644bc8d | |||
| 47e3802293 | |||
| a30db55024 | |||
| 53dec689b3 | |||
| 861086bf33 | |||
| ca2160d42f | |||
| d193d0992e | |||
| 0620ffd735 | |||
| 76fe9ba311 | |||
| 0b49a959f4 | |||
| 72e5e31a31 | |||
| 1389c26895 | |||
| 69776d9b77 | |||
| 806a798b87 | |||
| 6f7495703c | |||
| 9cb49ec4b9 | |||
| ade768b94c | |||
| d3561dd5c9 | |||
| d899bf945e | |||
| 6ca195fd9f | |||
| abd7a4a35e | |||
| 788c516fd6 | |||
| 2919722dab | |||
| 6892b6ac44 | |||
| 46a9701b62 | |||
| 4b4d5c714b | |||
| 645fbc66c6 | |||
| 8f936fc92c | |||
| 3a1fc7e4ac | |||
| d22d470aa2 | |||
| 8cd80ae7d2 | |||
| 6a4f81dd32 | |||
| dd20e42cb2 | |||
| 63fb1339b8 | |||
| c2a90265d2 | |||
| 53fe8c08a9 | |||
| 5a274f844c | |||
| 4c728ef7b6 | |||
| 79bc17912a | |||
| 236a148d42 | |||
| c9889d4abe | |||
| bc22f33a0c | |||
| 755954425e | |||
| 92cbcfeefd | |||
| aab196c26b | |||
| d306b01260 | |||
| 9cf3b51024 | |||
| 6f762534fe | |||
| c8af0fa5ca | |||
| ac920b997a | |||
| 7e2476b250 | |||
| c5552a94fb | |||
| 8168bfb2dc | |||
| d73b8b06ef | |||
| f3a3bc90b3 | |||
| 756c2bff32 | |||
| 6b195d0514 | |||
| b8fbb0d1d6 | |||
| fd6c79d3a2 | |||
| f350cd0169 | |||
| 4a18318cb9 | |||
| ce04701616 | |||
| b37120341f | |||
| 7f0b7756e4 | |||
| 80c2658b06 | |||
| 995fc4b591 | |||
| 240a947bec | |||
| a2091b1a67 | |||
| fc1f3dd903 | |||
| 4f1b9ac3f2 | |||
| 188defdf1b | |||
| eab0ed1b80 | |||
| 3b972efcdc | |||
| 23d6a1ad44 | |||
| 2706d81267 | |||
| ed138fdc57 | |||
| 2fd3f04f79 | |||
| 883e7c72f0 | |||
| cb33aabb0c | |||
| fe87e9038a | |||
| c4b3892d9c | |||
| adccf3bd2a | |||
| 2b1bbb9c94 | |||
| bb03cd94d6 | |||
| 6ae5daffa2 | |||
| 614b813056 | |||
| 33ce8b115c | |||
| 34cf1235c2 | |||
| cf85a560e4 | |||
| 4684c4a1eb | |||
| e69953ad17 | |||
| b50661d9ee | |||
| a8341d456d | |||
| 1ce287cb2e | |||
| 6798a5da7e | |||
| 7425c412fc | |||
| 7f64651517 | |||
| d49fdd24fc | |||
| db260008a2 | |||
| 8ea724116c | |||
| 94b20b0c54 | |||
| 0e5caf6b3f | |||
| 47db66b70b | |||
| 25e2c29e2e | |||
| b5eebb0acc | |||
| f3d6ef948b | |||
| 1cdbfd035d | |||
| ca9ef82caf | |||
| b7d90f9b18 | |||
| 3be42ec37a | |||
| 9565911089 | |||
| 9a375740b9 | |||
| b7057745a3 | |||
| a89d516623 | |||
| cf39c169d2 | |||
| 1ad1f1c010 | |||
| 1e6a255fab | |||
| a78178b5dd | |||
| 6d3eaa4471 | |||
| 79c3cfc1f0 | |||
| dac5c6c052 | |||
| b4beaf5bc9 | |||
| d563e2eac8 | |||
| 267beea8f9 | |||
| 4237740d32 | |||
| 5a1a2f98b0 | |||
| ed4b06d330 | |||
| 23dc30b5f9 | |||
| af841ace19 | |||
| f79dc2a26e | |||
| 8ce3452125 | |||
| 12e9115a6a | |||
| eeb4822b37 | |||
| 0632981d88 | |||
| a013755ce4 | |||
| 8240e693fb | |||
| 6a02a2b4e5 | |||
| 903999a262 | |||
| 5d94419d9f | |||
| 3d3c918848 | |||
| d0a3b5d6a4 | |||
| 4f2aea75f5 | |||
| 7be52a964e | |||
| d4514aa37d | |||
| 723f25bb59 | |||
| 1522416287 | |||
| 83402f84d5 | |||
| 605d940445 | |||
| 963a1f0c93 | |||
| d32b0d414f | |||
| ce53f7c879 | |||
| 0dd77817df | |||
| 3032bcd418 | |||
| 183c8e6d29 | |||
| c1b587aed4 | |||
| e7979baf76 | |||
| 3850d8636e | |||
| d3daa01667 | |||
| 838820f558 | |||
| a1ab5f512a | |||
| bb3c40594f | |||
| 6fd6acc716 | |||
| 623edf7254 | |||
| 32d5579d56 | |||
| 3605d77135 | |||
| da5ee0a76b | |||
| ebc482cc8f | |||
| 4fe546091f | |||
| 16d3a9b535 | |||
| 23496adb3a | |||
| bca298cbfe | |||
| fe90cfd99f | |||
| 33da807dcc | |||
| 29305f66bf | |||
| d728af427c | |||
| 2ac5d57b75 | |||
| 167b05e75b | |||
| 2546f542e7 | |||
| 885b24bfa9 | |||
| 7fb136b6ef | |||
| 155b8e6d5c | |||
| 8d6026b62a | |||
| 7632acfbd8 | |||
| 7de88eab36 | |||
| 9ce2eb65f1 | |||
| 6d3af46d73 | |||
| a04de05544 | |||
| 5bec1393fc | |||
| 7b8bbf024a | |||
| 78d24d2d15 | |||
| 53a5355600 | |||
| ac753d090f | |||
| 0cfcd8282c | |||
| 0649741a1c | |||
| d9495abab1 | |||
| 2e673f0d55 | |||
| 82aa63edd5 | |||
| da49140bff | |||
| 039ae15559 | |||
| c5b04891ce | |||
| 2261bf6ba3 | |||
| 9f229962e2 | |||
| 681f09f28c | |||
| f21bcdd6bb | |||
| d3ec76dc0f | |||
| fed6102980 | |||
| e7f03b1bf5 | |||
| 7b5148d089 | |||
| ceb6b8de3d | |||
| 505d9dbdcd |
+4
-3
@@ -107,7 +107,7 @@ replit.md
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.zip
|
||||
!src/payload/*.zip
|
||||
!source/payload/*.zip
|
||||
artifacts/
|
||||
release/
|
||||
releases/
|
||||
@@ -121,7 +121,8 @@ releases/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
site/
|
||||
/site/
|
||||
!source/packages/*/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
@@ -160,7 +161,7 @@ package-lock.json
|
||||
# PHP / Composer tooling
|
||||
# ============================================================
|
||||
vendor/
|
||||
!src/media/vendor/
|
||||
!source/media/vendor/
|
||||
composer.lock
|
||||
*.phar
|
||||
codeception.phar
|
||||
|
||||
+61
-295
@@ -1,316 +1,82 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# MokoSuite
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
Joomla 5/6 admin tools suite — heartbeat health monitoring, extension management, security firewall, tenant restrictions, and site administration.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
## Quick Reference
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Templates.GitHub
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
PATH: /templates/github/copilot-instructions.joomla.md.template
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: GitHub Copilot custom instructions template for Joomla/MokoWaaS governed repositories
|
||||
NOTE: Synced to .github/copilot-instructions.md in all Joomla/WaaS repos via bulk sync.
|
||||
Tokens replaced at sync time: MokoWaaS, https://github.com/mokoconsulting-tech/MokoWaaS, {{EXTENSION_NAME}},
|
||||
{{EXTENSION_TYPE}}, {{EXTENSION_ELEMENT}}
|
||||
-->
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokosuite` |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [MokoSuite Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki) |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **🔧 AI Self-Update Required on First Use**
|
||||
>
|
||||
> This file may contain unfilled `{{TOKEN}}` placeholders. The MokoStandards bulk-sync system
|
||||
> replaces them automatically at sync time; if you can still see them, sync has not yet run for
|
||||
> this repository — or this is a brand-new repo.
|
||||
>
|
||||
> **On your very first interaction in this repository, before answering any other question:**
|
||||
>
|
||||
> 1. Check whether any `{{TOKEN}}` placeholders remain in this file.
|
||||
> 2. If they do, locate the values as described in the table below and replace every occurrence
|
||||
> in **both** `.github/copilot-instructions.md` and `.github/CLAUDE.md`.
|
||||
> 3. After replacing all tokens, **delete this entire notice block** (from `> [!IMPORTANT]`
|
||||
> through the closing `> ---` line) in both files.
|
||||
> 4. Tell the developer: *"I've self-updated the AI context files with this repository's
|
||||
> values — all set."*
|
||||
>
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `MokoWaaS` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `https://github.com/mokoconsulting-tech/MokoWaaS` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
> | `{{EXTENSION_ELEMENT}}` | The `<element>` tag in `manifest.xml`, or the filename prefix (e.g. `com_myextension`, `mod_mymodule`) |
|
||||
>
|
||||
> ---
|
||||
## Commands
|
||||
|
||||
# MokoWaaS — GitHub Copilot Custom Instructions
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
|
||||
Repository URL: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
Extension name: **{{EXTENSION_NAME}}**
|
||||
Extension type: **{{EXTENSION_TYPE}}** (`{{EXTENSION_ELEMENT}}`)
|
||||
Platform: **Joomla 4.x / MokoWaaS**
|
||||
|
||||
---
|
||||
|
||||
## Primary Language
|
||||
|
||||
**PHP** (≥ 7.4) is the primary language for this Joomla extension. JavaScript may be used for frontend enhancements. YAML uses 2-space indentation. All other text files use tabs per `.editorconfig`.
|
||||
|
||||
---
|
||||
|
||||
## File Header — Always Required on New Files
|
||||
|
||||
Every new file needs a copyright header as its first content.
|
||||
|
||||
**PHP:**
|
||||
```php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoWaaS.{{EXTENSION_TYPE}}
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
* PATH: /path/to/file.php
|
||||
* VERSION: XX.YY.ZZ
|
||||
* BRIEF: One-line description of purpose
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
**Markdown:**
|
||||
```markdown
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
## Architecture
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
Joomla **package** (`pkg_mokosuite`) with 17 sub-extensions:
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
### Core Plugin (`plg_system_mokosuite`)
|
||||
- Heartbeat health endpoint (`/?mokosuite=health`) with 16 diagnostic checks
|
||||
- Grafana provisioning and heartbeat sender
|
||||
- Site alias / domain management
|
||||
- Extension cascade (enable/disable coordination)
|
||||
- Download key preservation across Joomla updates
|
||||
- Namespace: `Moko\Plugin\System\MokoSuite`
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoWaaS.Documentation
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
PATH: /docs/file.md
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: One-line description
|
||||
-->
|
||||
```
|
||||
### Feature Plugins
|
||||
- `plg_system_mokosuite_firewall` — WAF, IP blocklist, security headers, password policy
|
||||
- `plg_system_mokosuite_tenant` — admin restrictions for non-master users
|
||||
- `plg_system_mokosuite_devtools` — dev mode, hit reset, version cleanup, download key reset
|
||||
- `plg_system_mokosuite_offline` — offline mode bypass for legal pages
|
||||
- `plg_system_mokosuite_monitor` — Grafana heartbeat registration
|
||||
|
||||
**YAML / Shell / XML:** Use the appropriate comment syntax with the same fields. JSON files are exempt.
|
||||
### Component (`com_mokosuite`)
|
||||
- Admin dashboard with plugin management, WAF charts, extension catalog
|
||||
- Helpdesk ticketing system
|
||||
- REST API controllers
|
||||
|
||||
---
|
||||
### Modules
|
||||
- `mod_mokosuite_cpanel` — admin dashboard widget
|
||||
- `mod_mokosuite_menu` — admin sidebar menu
|
||||
- `mod_mokosuite_cache` — status bar cache/temp cleaner
|
||||
- `mod_mokosuite_categories` — auto-category tree menu
|
||||
|
||||
## Version Management
|
||||
### Task Plugins
|
||||
- `plg_task_mokosuitedemo` — scheduled demo site reset
|
||||
- `plg_task_mokosuitesync` — scheduled content sync
|
||||
- `plg_task_mokosuite_tickets` — ticket automation
|
||||
|
||||
**`README.md` is the single source of truth for the repository version.**
|
||||
### Update Server
|
||||
|
||||
- **Bump the patch version on every PR** — increment `XX.YY.ZZ` (e.g. `01.02.03` → `01.02.04`) in `README.md` before opening the PR; the `sync-version-on-merge` workflow propagates it automatically to all badges and `FILE INFORMATION` headers on merge to `main`.
|
||||
- The `VERSION: XX.YY.ZZ` field in `README.md` governs all other version references.
|
||||
- Version format is zero-padded semver: `XX.YY.ZZ` (e.g. `01.02.03`).
|
||||
- Never hardcode a specific version in document body text — use the badge or FILE INFORMATION header only.
|
||||
MokoGitea generates update feeds dynamically from releases — no static `updates.xml` needed.
|
||||
|
||||
### Joomla Version Alignment
|
||||
## Source Directory
|
||||
|
||||
The version in `README.md` **must always match** the `<version>` tag in `manifest.xml` and the latest entry in `updates.xml`. The `make release` command / release workflow updates all three automatically.
|
||||
Source lives in `source/` (not `src/`):
|
||||
- `source/pkg_mokosuite.xml` — package manifest
|
||||
- `source/script.php` — install script
|
||||
- `source/packages/` — all sub-extensions
|
||||
|
||||
```xml
|
||||
<!-- In manifest.xml — must match README.md version -->
|
||||
<version>01.02.04</version>
|
||||
## Rules
|
||||
|
||||
<!-- In updates.xml — prepend a new <update> block for every release.
|
||||
Note: the backslash in version="4\.[0-9]+" is a literal backslash character
|
||||
in the XML attribute value. Joomla's update server treats the value as a
|
||||
regular expression, so \. matches a literal dot. -->
|
||||
<updates>
|
||||
<update>
|
||||
<name>{{EXTENSION_NAME}}</name>
|
||||
<version>01.02.04</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/MokoWaaS/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="4\.[0-9]+" />
|
||||
</update>
|
||||
<!-- … older entries preserved below … -->
|
||||
</updates>
|
||||
```
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
---
|
||||
## Coding Standards
|
||||
|
||||
## Joomla Extension Structure
|
||||
|
||||
```
|
||||
MokoWaaS/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── (no updates.xml) # Update XML is generated dynamically by MokoGitea
|
||||
├── site/ # Frontend (site) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ └── views/
|
||||
├── admin/ # Backend (admin) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ ├── views/
|
||||
│ └── sql/
|
||||
├── language/ # Language INI files
|
||||
├── media/ # CSS, JS, images (deployed to /media/{{EXTENSION_ELEMENT}}/)
|
||||
├── docs/ # Technical documentation
|
||||
├── tests/ # Test suite
|
||||
├── .github/
|
||||
│ ├── workflows/
|
||||
│ ├── copilot-instructions.md # This file
|
||||
│ └── CLAUDE.md
|
||||
├── README.md # Version source of truth
|
||||
├── CHANGELOG.md
|
||||
├── CONTRIBUTING.md
|
||||
├── LICENSE # GPL-3.0-or-later
|
||||
└── Makefile # Build automation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Server — MokoGitea Dynamic Endpoint
|
||||
|
||||
`updates.xml` is **NOT** stored in the repo. MokoGitea generates the update XML dynamically from git releases at:
|
||||
|
||||
```
|
||||
https://git.mokoconsulting.tech/{Owner}/{Repo}/updates.xml
|
||||
```
|
||||
|
||||
The package manifest (`pkg_mokowaas.xml`) references it via:
|
||||
```xml
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="MokoWaaS Update Server">
|
||||
https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
|
||||
**License Key (Download Key):**
|
||||
- MokoGitea's endpoint validates license keys passed as `?dlid=MOKO-XXXX-XXXX-XXXX-XXXX`
|
||||
- The generated XML includes `<downloadkey prefix="dlid=" suffix="" />` to tell Joomla a key is required
|
||||
- Users enter the download key via Joomla's native **System → Update Sites** interface
|
||||
- Joomla stores the key in `#__update_sites.extra_query` and appends it to all update/download requests
|
||||
- Invalid/expired keys receive an empty `<updates></updates>` response
|
||||
|
||||
**Rules:**
|
||||
- Do NOT create or commit a static `updates.xml` — MokoGitea generates it from releases
|
||||
- The `<version>` in release tags must match `<version>` in the manifest and `README.md`
|
||||
- Release assets (ZIPs) must be attached to git releases — MokoGitea uses them for `<downloadurl>`
|
||||
- `<targetplatform name="joomla" version="(5|6)\..*">` — the backslash is a **literal backslash character** in the XML attribute value; Joomla's update-server parser treats the value as a regular expression
|
||||
|
||||
---
|
||||
|
||||
## manifest.xml Rules
|
||||
|
||||
- Lives at the repo root as `manifest.xml` (not inside `site/` or `admin/`).
|
||||
- `<version>` tag must be kept in sync with `README.md` version and `updates.xml`.
|
||||
- Must include `<updateservers>` block pointing to this repo's `updates.xml`.
|
||||
- Must include `<files folder="site">` and `<administration>` sections.
|
||||
- Joomla 4.x requires `<namespace path="src">Moko\{{EXTENSION_NAME}}</namespace>` for namespaced extensions.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions — Token Usage
|
||||
|
||||
Every workflow must use **`secrets.GH_TOKEN`** (the org-level Personal Access Token).
|
||||
|
||||
```yaml
|
||||
# ✅ Correct
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ❌ Wrong — never use these in workflows
|
||||
token: ${{ github.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MokoStandards Reference
|
||||
|
||||
This repository is governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Authoritative policies:
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Context | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| PHP class | `PascalCase` | `MyController` |
|
||||
| PHP method / function | `camelCase` | `getItems()` |
|
||||
| PHP variable | `$snake_case` | `$item_id` |
|
||||
| PHP constant | `UPPER_SNAKE_CASE` | `MAX_ITEMS` |
|
||||
| PHP class file | `PascalCase.php` | `ItemModel.php` |
|
||||
| YAML workflow | `kebab-case.yml` | `ci-joomla.yml` |
|
||||
| Markdown doc | `kebab-case.md` | `installation-guide.md` |
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Format: `<type>(<scope>): <subject>` — imperative, lower-case subject, no trailing period.
|
||||
|
||||
Valid types: `feat` · `fix` · `docs` · `chore` · `ci` · `refactor` · `style` · `test` · `perf` · `revert` · `build`
|
||||
|
||||
---
|
||||
|
||||
## Branch Naming
|
||||
|
||||
Format: `<prefix>/<MAJOR.MINOR.PATCH>[/description]`
|
||||
|
||||
Approved prefixes: `dev/` · `rc/` · `version/` · `patch/` · `copilot/` · `dependabot/`
|
||||
|
||||
---
|
||||
|
||||
## Keeping Documentation Current
|
||||
|
||||
| Change type | Documentation to update |
|
||||
|-------------|------------------------|
|
||||
| New or renamed PHP class/method | PHPDoc block; `docs/api/` entry |
|
||||
| New or changed manifest.xml | Bump README.md version |
|
||||
| New release | Create git release with ZIP asset; update CHANGELOG.md; bump README.md version |
|
||||
| New or changed workflow | `docs/workflows/<workflow-name>.md` |
|
||||
| Any modified file | Update the `VERSION` field in that file's `FILE INFORMATION` block |
|
||||
| **Every PR** | **Bump the patch version** — increment `XX.YY.ZZ` in `README.md`; `sync-version-on-merge` propagates it |
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- Never commit directly to `main` — all changes go via PR, squash-merged
|
||||
- Never skip the FILE INFORMATION block on a new file
|
||||
- Never add `defined('_JEXEC') or die;` to CLI scripts or model tests — only to web-accessible PHP files
|
||||
- Never hardcode version numbers in body text — update `README.md` and let automation propagate
|
||||
- Never use `github.token` or `secrets.GITHUB_TOKEN` in workflows — always use `secrets.GH_TOKEN`
|
||||
- Never let `manifest.xml` version and `README.md` version go out of sync
|
||||
- Never commit a static `updates.xml` — the update feed is generated dynamically by MokoGitea
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla 5/6 DI container pattern: `services/provider.php` → Extension class
|
||||
- `SubscriberInterface` for event subscription
|
||||
- Joomla 5/6 dual-compat for events: check `is_object($event)` with `getArgument()` fallback
|
||||
- SPDX license headers on all PHP files
|
||||
- `defined('_JEXEC') or die;` on all web-accessible PHP files
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: WaaS Client Site Issue
|
||||
about: Report an issue with a WaaS client site (branding, deployment, media sync)
|
||||
name: Suite Client Site Issue
|
||||
about: Report an issue with a Suite client site (branding, deployment, media sync)
|
||||
title: '[WAAS] '
|
||||
labels: 'waas, client-site'
|
||||
assignees: ''
|
||||
@@ -52,7 +52,7 @@ Attach screenshots showing the issue (desktop and mobile if relevant).
|
||||
## Template Details
|
||||
- **Joomla Version**: [e.g., 5.x]
|
||||
- **Template Name**: [e.g., clienttemplate]
|
||||
- **MokoWaaS Plugin**: [Active / Inactive]
|
||||
- **MokoSuite Plugin**: [Active / Inactive]
|
||||
- **MokoOnyx Admin**: [Active / Inactive]
|
||||
|
||||
## CSS Custom Properties
|
||||
@@ -11,9 +11,9 @@ INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
PATH: /templates/github/copilot-instructions.joomla.md.template
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: GitHub Copilot custom instructions template for Joomla/MokoWaaS governed repositories
|
||||
NOTE: Synced to .github/copilot-instructions.md in all Joomla/WaaS repos via bulk sync.
|
||||
Tokens replaced at sync time: MokoWaaS, https://github.com/mokoconsulting-tech/MokoWaaS, {{EXTENSION_NAME}},
|
||||
BRIEF: GitHub Copilot custom instructions template for Joomla/MokoSuite governed repositories
|
||||
NOTE: Synced to .github/copilot-instructions.md in all Joomla/Suite repos via bulk sync.
|
||||
Tokens replaced at sync time: MokoSuite, https://github.com/mokoconsulting-tech/MokoSuite, {{EXTENSION_NAME}},
|
||||
{{EXTENSION_TYPE}}, {{EXTENSION_ELEMENT}}
|
||||
-->
|
||||
|
||||
@@ -36,24 +36,24 @@ NOTE: Synced to .github/copilot-instructions.md in all Joomla/WaaS repos via bul
|
||||
>
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `MokoWaaS` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `https://github.com/mokoconsulting-tech/MokoWaaS` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `MokoSuite` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `https://github.com/mokoconsulting-tech/MokoSuite` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
> | `{{EXTENSION_ELEMENT}}` | The `<element>` tag in `manifest.xml`, or the filename prefix (e.g. `com_myextension`, `mod_mymodule`) |
|
||||
>
|
||||
> ---
|
||||
|
||||
# MokoWaaS — GitHub Copilot Custom Instructions
|
||||
# MokoSuite — GitHub Copilot Custom Instructions
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
This is a **Moko Consulting MokoSuite** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
|
||||
Repository URL: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
Repository URL: https://github.com/mokoconsulting-tech/MokoSuite
|
||||
Extension name: **{{EXTENSION_NAME}}**
|
||||
Extension type: **{{EXTENSION_TYPE}}** (`{{EXTENSION_ELEMENT}}`)
|
||||
Platform: **Joomla 4.x / MokoWaaS**
|
||||
Platform: **Joomla 4.x / MokoSuite**
|
||||
|
||||
---
|
||||
|
||||
@@ -77,9 +77,9 @@ Every new file needs a copyright header as its first content.
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoWaaS.{{EXTENSION_TYPE}}
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
* DEFGROUP: MokoSuite.{{EXTENSION_TYPE}}
|
||||
* INGROUP: MokoSuite
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoSuite
|
||||
* PATH: /path/to/file.php
|
||||
* VERSION: XX.YY.ZZ
|
||||
* BRIEF: One-line description of purpose
|
||||
@@ -98,9 +98,9 @@ This file is part of a Moko Consulting project.
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoWaaS.Documentation
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
DEFGROUP: MokoSuite.Documentation
|
||||
INGROUP: MokoSuite
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuite
|
||||
PATH: /docs/file.md
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: One-line description
|
||||
@@ -138,7 +138,7 @@ The version in `README.md` **must always match** the `<version>` tag in `manifes
|
||||
<version>01.02.04</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/MokoWaaS/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip
|
||||
https://github.com/mokoconsulting-tech/MokoSuite/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="4\.[0-9]+" />
|
||||
@@ -152,7 +152,7 @@ The version in `README.md` **must always match** the `<version>` tag in `manifes
|
||||
## Joomla Extension Structure
|
||||
|
||||
```
|
||||
MokoWaaS/
|
||||
MokoSuite/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── (no updates.xml) # Update XML is generated dynamically by MokoGitea
|
||||
├── site/ # Frontend (site) code
|
||||
@@ -191,11 +191,11 @@ MokoWaaS/
|
||||
https://git.mokoconsulting.tech/{Owner}/{Repo}/updates.xml
|
||||
```
|
||||
|
||||
The package manifest (`pkg_mokowaas.xml`) references it via:
|
||||
The package manifest (`pkg_mokosuite.xml`) references it via:
|
||||
```xml
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="MokoWaaS Update Server">
|
||||
https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml
|
||||
<server type="extension" priority="1" name="MokoSuite Update Server">
|
||||
https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
@@ -257,7 +257,7 @@ This repository is governed by [MokoStandards](https://github.com/mokoconsulting
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoSuite Joomla extension development guide |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>MokoWaaS</name>
|
||||
<display-name>Package - MokoWaaS</display-name>
|
||||
<name>MokoSuite</name>
|
||||
<display-name>Package - MokoSuite</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
|
||||
<version>02.32.10</version>
|
||||
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
|
||||
<version>02.34.52</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
@@ -21,6 +21,6 @@
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>package</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
<entry-point>source/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# +========================================================================+
|
||||
@@ -17,7 +17,7 @@
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
@@ -71,20 +71,25 @@ jobs:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
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,7 +105,7 @@ 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 }}"
|
||||
|
||||
@@ -108,7 +113,7 @@ jobs:
|
||||
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" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -131,31 +136,97 @@ 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
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||
# Feature/dev branches: bump minor for the new stable release
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
case "$HEAD_REF" in
|
||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||
*) BUMP="minor" ;;
|
||||
esac
|
||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
BUMP_FLAG=""
|
||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||
fi
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
@@ -167,7 +238,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" \
|
||||
@@ -241,7 +312,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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.32.10
|
||||
# VERSION: 02.34.52
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -105,6 +105,19 @@ jobs:
|
||||
- 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: |
|
||||
@@ -134,6 +147,98 @@ jobs:
|
||||
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 }}"
|
||||
@@ -141,7 +246,7 @@ jobs:
|
||||
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)"
|
||||
echo "::warning::No Joomla manifest found (Suite site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
@@ -151,6 +256,13 @@ jobs:
|
||||
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)
|
||||
@@ -183,6 +295,162 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entries (PRs to main)
|
||||
if: github.base_ref == 'main'
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::error::CHANGELOG.md not found — required for releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract content between [Unreleased] and next ## heading
|
||||
ENTRIES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found && /^- /{count++} END{print count+0}' CHANGELOG.md)
|
||||
|
||||
if [ "$ENTRIES" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md has no entries under [Unreleased]. Add changelog entries before releasing."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No entries found under \`[Unreleased]\` in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add entries describing what changed before merging to main." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Changelog: ${ENTRIES} unreleased entries found"
|
||||
echo "## Changelog Check: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ENTRIES} entries under [Unreleased]" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
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
|
||||
@@ -234,3 +502,31 @@ jobs:
|
||||
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."
|
||||
|
||||
@@ -7,16 +7,22 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- 'fix/**'
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -39,11 +45,11 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
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 == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,49 +57,81 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
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
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# 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: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Auto-detect and update platform if not set in manifest
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
case "${{ github.ref_name }}" in
|
||||
rc) STABILITY="release-candidate" ;;
|
||||
alpha) STABILITY="alpha" ;;
|
||||
beta) STABILITY="beta" ;;
|
||||
*) STABILITY="development" ;;
|
||||
esac
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) 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"
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
@@ -118,11 +156,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
|
||||
@@ -133,7 +172,42 @@ jobs:
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
@@ -146,55 +220,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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
@@ -40,10 +39,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
@@ -138,101 +133,6 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -256,14 +156,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -370,14 +270,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -704,7 +604,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -767,3 +667,45 @@ jobs:
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
@@ -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
|
||||
+127
-41
@@ -11,30 +11,127 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP:
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
INGROUP: MokoSuite.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.34.52
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
## [02.32.00] - 2026-06-02
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
- plg_system_mokowaas_firewall — HTTPS enforcement, trusted IPs, session timeout, upload restrictions, password policy
|
||||
- plg_system_mokowaas_tenant — Installer, sysinfo, config, template, and menu restrictions for non-master users
|
||||
- plg_system_mokowaas_devtools — Dev mode, hit counter reset, content version cleanup
|
||||
- plg_system_mokowaas_monitor — Grafana heartbeat integration and health monitoring
|
||||
- MokoWaaSHelper utility class for shared master-user detection across feature plugins
|
||||
- RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ
|
||||
- Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server
|
||||
- Send Heartbeat button on health token field for manual heartbeat testing
|
||||
- Font Awesome 7 loaded in admin backend — picks up MokoOnyx Kit code if present, falls back to bundled FA7 Free or FA6 CDN
|
||||
- MokoWaaS → MokoSuite database table migration in install script (create new, copy data, drop old)
|
||||
- MokoWaaS → MokoSuite extension param migration — copies params from all old mokowaas plugins/modules/component, then removes old entries and filesystem remnants
|
||||
- Ticket contact linking — optional FK to Joomla contact records with display in list and detail views
|
||||
- Multi-assignee tickets — junction table supports multiple users and user groups per ticket
|
||||
- Customizable ticket statuses — admin-configurable lookup table replaces hardcoded ENUM (title, color, is_closed flag)
|
||||
- Customizable ticket priorities — admin-configurable lookup table with weight and color
|
||||
- Joomla custom fields integration for tickets (context: com_mokosuite.ticket) with field groups assignable per category
|
||||
- MokoWaaS/MokoWaaSHQ migration bridge repos with updates.xml redirecting existing installs to MokoSuite/HQ
|
||||
- Pre-release workflow triggers on push to dev/alpha/beta/rc branches (deployed to all 11 repos)
|
||||
|
||||
### Removed
|
||||
- PerfectPublisher webservices plugin (no longer needed)
|
||||
|
||||
### Fixed
|
||||
- Download key lost on update: cleanupStaleUpdateSites used old /raw/branch/main/ URL format, deleting the manifest-registered update site that held the key
|
||||
|
||||
## [02.35.00] - 2026-06-06
|
||||
|
||||
### Added
|
||||
- Core plugin stripped to heartbeat-only config (~5,500 lines removed)
|
||||
- Extension catalog (catalog.xml) with update server discovery (#186)
|
||||
- Download key preservation across Joomla updates (#187)
|
||||
- Remote login endpoint for MokoSuiteHQ auto-login
|
||||
- Provision reset API for new client setup (hits, versions, tokens)
|
||||
- Setup required banner after provision reset
|
||||
- Support verification PIN (MOKO-XXXX-XXXX)
|
||||
- mod_mokosuite_categories — auto-category tree menu (#184)
|
||||
- Cache/temp split button in status bar
|
||||
- Dashboard version tiles for component and modules
|
||||
- Monitor plugin sends full health payload to MokoSuiteHQ
|
||||
- Firewall: block_frontend_superuser, own trusted_ip_entry.xml
|
||||
- DevTools: reset download keys toggle
|
||||
|
||||
### Changed
|
||||
- Renamed src/ to source/ (#188)
|
||||
- Service classes relocated to owning plugins
|
||||
- API controller execute() signatures fixed (#183)
|
||||
- Joomla 5/6 event compatibility in DevTools and Monitor
|
||||
- Dead placeholder resolver removed from install script
|
||||
|
||||
### Fixed
|
||||
- Firewall subform paths after core cleanup
|
||||
- Missing Security Headers language strings
|
||||
|
||||
## [02.34.00] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- Database Tools view — table status, optimize, repair, session purge (#127)
|
||||
- Cache Cleanup view — directory size reporting and one-click cleanup (#128)
|
||||
- mod_mokosuite_cache — one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner)
|
||||
- mod_mokosuite_menu — collapsible admin sidebar menu using native MetisMenu classes (like Community Builder)
|
||||
- SSL certificate expiry monitoring in cpanel module (#148)
|
||||
- MokoSuite-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 mokosuite_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_mokosuite with site info bar, feature plugin grid, and quick actions
|
||||
- Feature plugin architecture — MokoSuite features split into toggleable plugins managed from the dashboard
|
||||
- plg_system_mokosuite_firewall — HTTPS enforcement, trusted IPs, session timeout, upload restrictions, password policy
|
||||
- plg_system_mokosuite_tenant — Installer, sysinfo, config, template, and menu restrictions for non-master users
|
||||
- plg_system_mokosuite_devtools — Dev mode, hit counter reset, content version cleanup
|
||||
- plg_system_mokosuite_monitor — Grafana heartbeat integration and health monitoring
|
||||
- MokoSuiteHelper utility class for shared master-user detection across feature plugins
|
||||
- AJAX plugin toggle — enable/disable feature plugins directly from the dashboard
|
||||
- Clear cache quick action on dashboard
|
||||
- Static updates.xml for update server (licensing system deferred)
|
||||
- Automatic param migration from core plugin to feature plugins on upgrade
|
||||
|
||||
### Changed
|
||||
- com_mokowaas upgraded from API-only to full admin component with dashboard views
|
||||
- com_mokosuite upgraded from API-only to full admin component with dashboard views
|
||||
- Package manifest updated with 4 new feature plugin entries (10 extensions total)
|
||||
- Update server URL changed to static raw file endpoint
|
||||
- Core plugin slimmed — security, tenant, devtools, and monitor features extracted to dedicated plugins
|
||||
@@ -43,7 +140,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
|
||||
@@ -51,7 +149,7 @@
|
||||
- Persistent admin warning when no license key is configured in Update Sites
|
||||
- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired
|
||||
- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records)
|
||||
- Content sync rewritten — bulk MokoWaaS API endpoints (syncclear + syncpush) replace per-item Joomla API calls
|
||||
- Content sync rewritten — bulk MokoSuite API endpoints (syncclear + syncpush) replace per-item Joomla API calls
|
||||
- Sync task per-instance config: target URL, health token, content type checkboxes (articles, categories, menus, modules)
|
||||
- Bulk sync completes in under 5 seconds (clear + push in 2-3 HTTP requests)
|
||||
- Asset table and nested set tree repair after sync push on target site
|
||||
@@ -71,12 +169,13 @@
|
||||
- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases
|
||||
- Basic branding config tab (brand name, company name, support URL)
|
||||
- Visual branding config tab (colors, icon, custom CSS)
|
||||
- WaaS Access config tab (master user toggle, master email)
|
||||
- Suite Access config tab (master user toggle, master email)
|
||||
- Content Sync config tab (targets now in scheduled tasks)
|
||||
- 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
|
||||
@@ -84,37 +183,24 @@
|
||||
### Fixed
|
||||
- Emergency access IP whitelist: empty `allowed_ips` now permits all IPs (was blocking everyone)
|
||||
- Emergency access reads `allowed_ips` from plugin params instead of global config
|
||||
- `plg_task_mokowaassync` — Joomla Scheduled Task plugin for automatic content sync to remote sites
|
||||
- `plg_task_mokosuitesync` — Joomla Scheduled Task plugin for automatic content sync to remote sites
|
||||
- Community Builder tables added to demo reset safe table list
|
||||
- API endpoint `POST /api/index.php/v1/mokowaas/install` — install extensions from a remote ZIP URL
|
||||
- API endpoint `POST /api/index.php/v1/mokosuite/install` — install extensions from a remote ZIP URL
|
||||
|
||||
- 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)
|
||||
- REST endpoints `POST /api/v1/mokowaas/reset` and `GET/POST /api/v1/mokowaas/snapshot`
|
||||
- `plg_task_mokowaasdemo` — Joomla Scheduled Task plugin for automatic demo site reset
|
||||
- API endpoints `POST /?mokosuite=reset` and `POST /?mokosuite=snapshot` (query-string)
|
||||
- REST endpoints `POST /api/v1/mokosuite/reset` and `GET/POST /api/v1/mokosuite/snapshot`
|
||||
- `plg_task_mokosuitedemo` — Joomla Scheduled Task plugin for automatic demo site reset
|
||||
- Admin toggles: Take Snapshot Now and Restore Baseline Now in plugin config
|
||||
- Content Sync: one-way push of articles, categories, menus, and modules to remote MokoWaaS sites
|
||||
- Content Sync: API endpoints `POST /?mokowaas=sync` (sender) and `POST /?mokowaas=sync-receive` (receiver)
|
||||
- Content Sync: REST endpoints `POST /api/v1/mokowaas/sync` and `POST /api/v1/mokowaas/sync-receive`
|
||||
- Content Sync: one-way push of articles, categories, menus, and modules to remote MokoSuite sites
|
||||
- Content Sync: API endpoints `POST /?mokosuite=sync` (sender) and `POST /?mokosuite=sync-receive` (receiver)
|
||||
- Content Sync: REST endpoints `POST /api/v1/mokosuite/sync` and `POST /api/v1/mokosuite/sync-receive`
|
||||
- Content Sync: configurable sync targets with URL + API token in plugin settings
|
||||
- Package installer: protect all MokoWaaS extensions (not just system plugin) and ensure update server stays enabled
|
||||
- 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
|
||||
- Package installer: protect all MokoSuite extensions (not just system plugin) and ensure update server stays enabled
|
||||
- Package installer: clean up legacy `mokosuitebrand` extension entries and files on install/update
|
||||
- API endpoint `GET /?mokosuite=extensions` and `GET /api/v1/mokosuite/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,42 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MokoWaaS** -- MokoWaaS is a Joomla 5.x / 6.x system plugin that provides a configurable white-label identity layer for the MokoWaaS platform.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | joomla |
|
||||
| **Language** | PHP |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Wiki** | [MokoWaaS Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Joomla extension. Key directories:
|
||||
- `src/` -- extension source (deployed to Joomla)
|
||||
- `src/*.xml` -- manifest file (version, files, params)
|
||||
- `src/src/` or `src/services/` -- PHP classes
|
||||
- `src/language/` -- translation strings
|
||||
- `src/media/` -- CSS/JS/images
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
+3
-3
@@ -12,9 +12,9 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+185
-161
@@ -1,161 +1,185 @@
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
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
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
|
||||
+8
-8
@@ -16,12 +16,12 @@
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
FILE INFORMATION
|
||||
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
|
||||
DEFGROUP: mokoconsulting-tech.MokoSuiteBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
|
||||
VERSION: 02.32.10
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand
|
||||
VERSION: 02.34.52
|
||||
PATH: /GOVERNANCE.md
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
|
||||
-->
|
||||
|
||||
[](https://github.com/mokoconsulting-tech/MokoStandards)
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the governance model for the `MokoWaaSBrand` repository within the
|
||||
This document defines the governance model for the `MokoSuiteBrand` repository within the
|
||||
`mokoconsulting-tech` organization. It is automatically maintained by
|
||||
[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) v04.00.04.
|
||||
|
||||
@@ -97,7 +97,7 @@ See the full policy:
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/MokoWaaSBrand/issues)
|
||||
- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/MokoSuiteBrand/issues)
|
||||
- **Security vulnerabilities**: See [SECURITY.md](./SECURITY.md)
|
||||
- **Code of Conduct**: See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
|
||||
- **Contact**: dev@mokoconsulting.tech
|
||||
@@ -110,10 +110,10 @@ See the full policy:
|
||||
| ------------- | ----------------------------------------------- |
|
||||
| Document Type | Policy |
|
||||
| Domain | Governance |
|
||||
| Applies To | mokoconsulting-tech/MokoWaaSBrand |
|
||||
| Applies To | mokoconsulting-tech/MokoSuiteBrand |
|
||||
| Jurisdiction | Tennessee, USA |
|
||||
| Maintainer | @mokoconsulting-tech |
|
||||
| Standards | MokoStandards v04.00.04 |
|
||||
| Repo | https://github.com/mokoconsulting-tech/MokoWaaSBrand |
|
||||
| Repo | https://github.com/mokoconsulting-tech/MokoSuiteBrand |
|
||||
| Path | /GOVERNANCE.md |
|
||||
| Status | Active — auto-maintained by MokoStandards |
|
||||
|
||||
+3
-3
@@ -12,10 +12,10 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
INGROUP: MokoSuite.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
PATH: ./LICENSE.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.34.52
|
||||
BRIEF: Project license (GPL-3.0-or-later)
|
||||
-->
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
@@ -7,27 +7,27 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /README.md
|
||||
BRIEF: MokoWaaS platform plugin for Joomla
|
||||
BRIEF: MokoSuite platform plugin for Joomla
|
||||
-->
|
||||
|
||||
# MokoWaaS
|
||||
# MokoSuite
|
||||
|
||||
[](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases)
|
||||
[](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/releases)
|
||||
[](LICENSE)
|
||||
[](https://www.joomla.org)
|
||||
[](https://www.php.net)
|
||||
|
||||
MokoWaaS is a Joomla 5.x / 6.x system plugin package that provides white-label branding, security hardening, tenant restrictions, health monitoring, and multi-domain management for the MokoWaaS platform.
|
||||
MokoSuite is a Joomla 5.x / 6.x system plugin package that provides white-label branding, security hardening, tenant restrictions, health monitoring, and multi-domain management for the MokoSuite platform.
|
||||
|
||||
## Features
|
||||
|
||||
- **White-Label Branding** — configurable brand name, company, support URL, colors, favicon, custom CSS
|
||||
- **Tenant Restrictions** — master user enforcement, installer/sysinfo/config/template access control
|
||||
- **Health Monitoring** — 16 diagnostic checks via `/?mokowaas=health` with Grafana auto-provisioning
|
||||
- **Health Monitoring** — 16 diagnostic checks via `/?mokosuite=health` with Grafana auto-provisioning
|
||||
- **Site Aliases** — per-alias offline mode, robots directives, backend redirect, canonical URLs
|
||||
- **Remote API** — 6 endpoints (health, install, update, cache, backup, info)
|
||||
- **Security Hardening** — HTTPS enforcement, session timeouts, password policy, upload restrictions
|
||||
@@ -40,19 +40,19 @@ MokoWaaS is a Joomla 5.x / 6.x system plugin package that provides white-label b
|
||||
|
||||
## Installation
|
||||
|
||||
Download the latest `pkg_mokowaas-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases) and install via **System → Install → Upload Package File**.
|
||||
Download the latest `pkg_mokosuite-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/releases) and install via **System → Install → Upload Package File**.
|
||||
|
||||
After installation, the package auto-enables and sets protected status.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available on the [MokoWaaS Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki):
|
||||
Full documentation is available on the [MokoSuite Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki):
|
||||
|
||||
- [Configuration Guide](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki/Configuration)
|
||||
- [Health Monitoring](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki/Health-Monitoring)
|
||||
- [Site Aliases](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki/Site-Aliases)
|
||||
- [API Endpoints](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki/API-Endpoints)
|
||||
- [Grafana Integration](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki/Grafana-Integration)
|
||||
- [Configuration Guide](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki/Configuration)
|
||||
- [Health Monitoring](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki/Health-Monitoring)
|
||||
- [Site Aliases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki/Site-Aliases)
|
||||
- [API Endpoints](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki/API-Endpoints)
|
||||
- [Grafana Integration](https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/wiki/Grafana-Integration)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.34.52
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
+23
-23
@@ -8,20 +8,20 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
INGROUP: MokoSuite.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/
|
||||
BRIEF: Build and packaging guide for the MokoWaaS system plugin
|
||||
BRIEF: Build and packaging guide for the MokoSuite system plugin
|
||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||
-->
|
||||
|
||||
# MokoWaaS Build Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Build Guide (VERSION: 02.34.52)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the complete build and packaging workflow for the MokoWaaS system plugin. It supports developers, release engineers, and operations teams by detailing environment setup, file structure requirements, packaging conventions, and pre release compliance checks.
|
||||
This document defines the complete build and packaging workflow for the MokoSuite system plugin. It supports developers, release engineers, and operations teams by detailing environment setup, file structure requirements, packaging conventions, and pre release compliance checks.
|
||||
|
||||
## 2. Build Requirements
|
||||
|
||||
@@ -40,13 +40,13 @@ Optional but recommended:
|
||||
|
||||
## 3. Repository Structure Overview
|
||||
|
||||
The repository should maintain a clean, predictable, and modular structure suitable for Joomla system plugins, WaaS platform governance, and automated build tooling. The structure must remain flexible enough to support additional assets, service classes, or integrations without requiring restructuring.
|
||||
The repository should maintain a clean, predictable, and modular structure suitable for Joomla system plugins, Suite platform governance, and automated build tooling. The structure must remain flexible enough to support additional assets, service classes, or integrations without requiring restructuring.
|
||||
|
||||
```text
|
||||
mokowaas/
|
||||
├── src/
|
||||
│ ├── mokowaas.php (main plugin file)
|
||||
│ ├── mokowaas.xml (plugin manifest)
|
||||
mokosuite/
|
||||
├── source/
|
||||
│ ├── mokosuite.php (main plugin file)
|
||||
│ ├── mokosuite.xml (plugin manifest)
|
||||
│ ├── services/ (service providers for DI)
|
||||
│ │ └── provider.php
|
||||
│ ├── language/ (plugin language files)
|
||||
@@ -110,7 +110,7 @@ Remove any unneeded files:
|
||||
Using CLI:
|
||||
|
||||
```bash
|
||||
zip -r mokowaas_v01.04.00.zip ./ -x "*.git*" "scripts/*" "docs/*"
|
||||
zip -r mokosuite_v01.04.00.zip ./ -x "*.git*" "scripts/*" "docs/*"
|
||||
```
|
||||
|
||||
Ensure excluded paths match release governance and do not remove required runtime files.
|
||||
@@ -150,7 +150,7 @@ Possible automations:
|
||||
After release:
|
||||
|
||||
* Update download links and release notes
|
||||
* Notify WaaS internal release channels
|
||||
* Notify Suite internal release channels
|
||||
* Update dependent templates or modules if required
|
||||
* Record the release in any internal environment or asset registry
|
||||
|
||||
@@ -161,7 +161,7 @@ A continuous integration and delivery pipeline is implemented using GitHub Actio
|
||||
### 8.1 Build and Validate Workflow (`.github/workflows/build.yml`)
|
||||
|
||||
```yaml
|
||||
name: Build and Validate MokoWaaS
|
||||
name: Build and Validate MokoSuite
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -192,23 +192,23 @@ jobs:
|
||||
|
||||
- name: Lint PHP and syntax check
|
||||
run: |
|
||||
echo "[INFO] Run php -l over src/ and any additional linting as needed."
|
||||
echo "[INFO] Run php -l over source/ and any additional linting as needed."
|
||||
|
||||
- name: Create build artifact
|
||||
run: |
|
||||
zip -r mokowaas_ci_build.zip ./ -x "*.git*" "docs/*" "scripts/*"
|
||||
zip -r mokosuite_ci_build.zip ./ -x "*.git*" "docs/*" "scripts/*"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mokowaas-build
|
||||
path: mokowaas_ci_build.zip
|
||||
name: mokosuite-build
|
||||
path: mokosuite_ci_build.zip
|
||||
```
|
||||
|
||||
### 8.2 Release Workflow (`.github/workflows/release.yml`)
|
||||
|
||||
```yaml
|
||||
name: Release MokoWaaS
|
||||
name: Release MokoSuite
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -226,14 +226,14 @@ jobs:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mokowaas-build
|
||||
name: mokosuite-build
|
||||
path: ./dist
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/mokowaas_ci_build.zip
|
||||
dist/mokosuite_ci_build.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
@@ -290,8 +290,8 @@ To prevent runtime failures, validate the following prior to packaging:
|
||||
|
||||
Required files:
|
||||
|
||||
* `mokowaas.xml`
|
||||
* `mokowaas.php`
|
||||
* `mokosuite.xml`
|
||||
* `mokosuite.php`
|
||||
* `services/provider.php`
|
||||
* Language files under `language/en-GB/`
|
||||
* LICENSE.md
|
||||
|
||||
@@ -8,25 +8,25 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/configuration-guide.md
|
||||
BRIEF: Configuration guide for the MokoWaaS system plugin
|
||||
BRIEF: Configuration guide for the MokoSuite system plugin
|
||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||
-->
|
||||
|
||||
# MokoWaaS Configuration Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Configuration Guide (VERSION: 02.34.52)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
This guide outlines the configuration parameters available within the MokoWaaS system plugin and establishes recommended defaults for WaaS governed environments. Proper configuration ensures consistent branding behavior across templates, modules, and administrative surfaces.
|
||||
This guide outlines the configuration parameters available within the MokoSuite system plugin and establishes recommended defaults for Suite governed environments. Proper configuration ensures consistent branding behavior across templates, modules, and administrative surfaces.
|
||||
|
||||
## 2. Accessing Plugin Configuration
|
||||
|
||||
1. Log in to Joomla Administrator.
|
||||
2. Navigate to **System > Plugins**.
|
||||
3. Search for **MokoWaaS**.
|
||||
3. Search for **MokoSuite**.
|
||||
4. Select the plugin name to open the configuration panel.
|
||||
|
||||
## 3. Plugin Parameters
|
||||
@@ -47,7 +47,7 @@ Master switch for all branding overrides. When disabled, no language overrides a
|
||||
| -------- | ----- |
|
||||
| Field name | `brand_name` |
|
||||
| Type | Text |
|
||||
| Default | `MokoWaaS` |
|
||||
| Default | `MokoSuite` |
|
||||
|
||||
The brand name that replaces "Joomla" throughout the interface. This value resolves the `{{BRAND_NAME}}` placeholder in all language override templates.
|
||||
|
||||
@@ -90,7 +90,7 @@ URL for support and documentation links. Resolves the `{{SUPPORT_URL}}` placehol
|
||||
|
||||
## 4. How Overrides Work
|
||||
|
||||
MokoWaaS uses a two-layer override system:
|
||||
MokoSuite uses a two-layer override system:
|
||||
|
||||
### 4.1 Runtime Resolution (Primary)
|
||||
|
||||
@@ -103,16 +103,16 @@ On every page load, the plugin reads override template files shipped with the pl
|
||||
During install/update, the install script resolves placeholders and writes the result into Joomla's global language override files inside a sentinel block:
|
||||
|
||||
```ini
|
||||
; ===== BEGIN MokoWaaS Overrides (do not edit this block) =====
|
||||
; ===== BEGIN MokoSuite Overrides (do not edit this block) =====
|
||||
; Auto-generated on 2026-04-07 — do not edit manually.
|
||||
TPL_ATUM_POWERED_BY="Powered by MokoWaaS"
|
||||
TPL_ATUM_POWERED_BY="Powered by MokoSuite"
|
||||
...
|
||||
; ===== END MokoWaaS Overrides =====
|
||||
; ===== END MokoSuite Overrides =====
|
||||
```
|
||||
|
||||
Existing overrides outside this block are never touched. On uninstall, only the MokoWaaS block (and any legacy stray keys) are removed.
|
||||
Existing overrides outside this block are never touched. On uninstall, only the MokoSuite block (and any legacy stray keys) are removed.
|
||||
|
||||
## 5. WaaS Access Control (fieldset: `waas_access`)
|
||||
## 5. Suite Access Control (fieldset: `waas_access`)
|
||||
|
||||
### 5.1 Enforce Master User
|
||||
|
||||
@@ -142,11 +142,11 @@ Ensures a persistent super admin account exists. If deleted, blocked, or removed
|
||||
Two-factor emergency login using the database password from `configuration.php`:
|
||||
|
||||
1. Login with master username + DB password
|
||||
2. Plugin creates `/mokowaas-verify.php` in site root
|
||||
2. Plugin creates `/mokosuite-verify.php` in site root
|
||||
3. Delete the file via FTP/SSH
|
||||
4. Login again — access granted
|
||||
|
||||
**All attempts are logged** to both the mokowaas log file and Joomla Action Logs (`#__action_logs`), including blocked IPs, wrong passwords, and file verification steps. Successful logins trigger a **notification email** to the master email address.
|
||||
**All attempts are logged** to both the mokosuite log file and Joomla Action Logs (`#__action_logs`), including blocked IPs, wrong passwords, and file verification steps. Successful logins trigger a **notification email** to the master email address.
|
||||
|
||||
### 5.4 IP Whitelist Display
|
||||
|
||||
@@ -154,7 +154,7 @@ A live info panel shows:
|
||||
* Number of IPs configured (or "Not configured" if empty)
|
||||
* List of allowed IPs with "your IP" badge when matching
|
||||
* Your current IP address
|
||||
* Instructions for setting `$mokowaas_allowed_ips` in `configuration.php`
|
||||
* Instructions for setting `$mokosuite_allowed_ips` in `configuration.php`
|
||||
|
||||
**Important:** Emergency access is **blocked** when no IPs are configured. An explicit whitelist is required.
|
||||
|
||||
@@ -167,13 +167,13 @@ One-shot actions that execute when set to Yes and saved. Auto-reset to No after
|
||||
| `reset_hits` | Sets all `#__content.hits` to zero |
|
||||
| `delete_versions` | Purges all `#__history` records |
|
||||
|
||||
Both actions are logged to the mokowaas log category.
|
||||
Both actions are logged to the mokosuite log category.
|
||||
|
||||
## 7. Visual Branding (fieldset: `visual_branding`)
|
||||
|
||||
### 7.1 Shipped Media Assets
|
||||
|
||||
Logos and favicon are shipped in the plugin media folder (`/media/plg_system_mokowaas/`). Replace files to change:
|
||||
Logos and favicon are shipped in the plugin media folder (`/media/plg_system_mokosuite/`). Replace files to change:
|
||||
|
||||
| File | Used for |
|
||||
| ---- | -------- |
|
||||
@@ -236,13 +236,13 @@ Restricted components are automatically hidden from the admin menu via `onPrepro
|
||||
1. Document the change request.
|
||||
2. Apply updates in a staging environment.
|
||||
3. Validate branding, restrictions, and security settings.
|
||||
4. Promote changes to production following WaaS change controls.
|
||||
4. Promote changes to production following Suite change controls.
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
* **Branding not appearing:** Clear Joomla and browser cache. Verify `enable_branding` is Yes.
|
||||
* **Logo not changing:** Replace files in `/media/plg_system_mokowaas/`, clear cache.
|
||||
* **Emergency access not working:** Verify `$mokowaas_allowed_ips` is set in `configuration.php` and includes your IP.
|
||||
* **Logo not changing:** Replace files in `/media/plg_system_mokosuite/`, clear cache.
|
||||
* **Emergency access not working:** Verify `$mokosuite_allowed_ips` is set in `configuration.php` and includes your IP.
|
||||
* **Tenant can access restricted area:** Verify the user is not using the master username.
|
||||
* **Password rejected:** Check password policy settings — all rules must pass.
|
||||
|
||||
@@ -266,4 +266,4 @@ Restricted components are automatically hidden from the admin menu via `onPrepro
|
||||
| Version | Date | Author | Description |
|
||||
| -------- | ---------- | ------------------------------- | ---------------------------------------------- |
|
||||
| 01.02.00 | 2025-12-11 | Jonathan Miller (@jmiller) | Initial standalone configuration guide created |
|
||||
| 02.01.08 | 2026-04-07 | Jonathan Miller (@jmiller) | Full rewrite: WaaS access, visual branding, tenant restrictions, security, maintenance, action logs |
|
||||
| 02.01.08 | 2026-04-07 | Jonathan Miller (@jmiller) | Full rewrite: Suite access, visual branding, tenant restrictions, security, maintenance, action logs |
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/installation-guide.md
|
||||
BRIEF: Installation guide for the MokoWaaS system plugin
|
||||
BRIEF: Installation guide for the MokoSuite system plugin
|
||||
NOTE: First document in the guide set
|
||||
-->
|
||||
|
||||
# MokoWaaS Installation Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Installation Guide (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS Installation Guide provides the authoritative process for deploying the system plugin within WaaS-managed Joomla environments. The installation ensures consistent application of MokoWaaS branding policy, identity governance, and terminology alignment across all administrative interfaces.
|
||||
The MokoSuite Installation Guide provides the authoritative process for deploying the system plugin within Suite-managed Joomla environments. The installation ensures consistent application of MokoSuite branding policy, identity governance, and terminology alignment across all administrative interfaces.
|
||||
|
||||
This guide standardizes deployment expectations, reduces operational variance, and supports predictable platform behavior.
|
||||
|
||||
@@ -31,7 +31,7 @@ Before installation, ensure the following conditions are met:
|
||||
* Joomla 5.x operational environment
|
||||
* PHP 8.1 or higher
|
||||
* Administrative access credentials
|
||||
* Validated MokoWaaS plugin package from an approved release channel
|
||||
* Validated MokoSuite plugin package from an approved release channel
|
||||
* Recommended: environment snapshot or backup prior to installation
|
||||
|
||||
## Obtaining the Package
|
||||
@@ -40,7 +40,7 @@ To maintain integrity and compliance:
|
||||
|
||||
1. Acquire the plugin package from the official MokoConsulting repository or release channel.
|
||||
2. Validate package checksum or digital signature if provided.
|
||||
3. Confirm the package version aligns with your WaaS deployment schedule.
|
||||
3. Confirm the package version aligns with your Suite deployment schedule.
|
||||
|
||||
## Installation Steps
|
||||
|
||||
@@ -49,7 +49,7 @@ Follow these steps to install the plugin:
|
||||
1. Log in to the Joomla Administrator dashboard.
|
||||
2. Navigate to **System > Extensions > Install**.
|
||||
3. Choose **Upload Package File**.
|
||||
4. Upload the MokoWaaS plugin package.
|
||||
4. Upload the MokoSuite plugin package.
|
||||
5. Confirm successful installation in the extension status message.
|
||||
|
||||
## Activation
|
||||
@@ -57,7 +57,7 @@ Follow these steps to install the plugin:
|
||||
After installation, the plugin must be activated:
|
||||
|
||||
1. Navigate to **System > Plugins**.
|
||||
2. Search for **MokoWaaS**.
|
||||
2. Search for **MokoSuite**.
|
||||
3. Confirm the plugin type is **System**.
|
||||
4. Set status to **Enabled**.
|
||||
5. Save and close.
|
||||
@@ -66,7 +66,7 @@ After installation, the plugin must be activated:
|
||||
|
||||
To ensure proper activation and system compatibility, verify the following:
|
||||
|
||||
* MokoWaaS branding appears in the administrator footer.
|
||||
* MokoSuite branding appears in the administrator footer.
|
||||
* Terminology updates apply consistently across admin UI.
|
||||
* No conflicts with templates, overrides, or extensions.
|
||||
* Joomla and PHP logs show no errors related to the plugin.
|
||||
|
||||
@@ -8,33 +8,33 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/operations-guide.md
|
||||
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
|
||||
BRIEF: Operational guide for administering and managing the MokoSuite system plugin
|
||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||
-->
|
||||
|
||||
# MokoWaaS Operations Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Operations Guide (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS Operations Guide defines how the plugin is managed across WaaS governed Joomla environments. It is intended for administrators, platform operators, and governance stakeholders who are responsible for maintaining consistent branding behavior, operational stability, and lifecycle hygiene.
|
||||
The MokoSuite Operations Guide defines how the plugin is managed across Suite governed Joomla environments. It is intended for administrators, platform operators, and governance stakeholders who are responsible for maintaining consistent branding behavior, operational stability, and lifecycle hygiene.
|
||||
|
||||
This document focuses on day to day responsibilities, monitoring expectations, and coordination points with other parts of the WaaS platform.
|
||||
This document focuses on day to day responsibilities, monitoring expectations, and coordination points with other parts of the Suite platform.
|
||||
|
||||
## Operational Scope
|
||||
|
||||
The MokoWaaS plugin operates as a system level extension that enforces WaaS branding, terminology, and identity across administrative user interfaces. Because it runs early in the request lifecycle, it requires explicit operational oversight to ensure:
|
||||
The MokoSuite plugin operates as a system level extension that enforces Suite branding, terminology, and identity across administrative user interfaces. Because it runs early in the request lifecycle, it requires explicit operational oversight to ensure:
|
||||
|
||||
* Consistent behavior after template or core updates
|
||||
* Stable interaction with other system plugins
|
||||
* Alignment with WaaS branding policy and governance
|
||||
* Alignment with Suite branding policy and governance
|
||||
|
||||
## Roles and Responsibilities
|
||||
|
||||
### WaaS Platform Administrators
|
||||
### Suite Platform Administrators
|
||||
|
||||
* Maintain the plugin at the approved version for each environment
|
||||
* Validate branding consistency following platform or template changes
|
||||
@@ -42,7 +42,7 @@ The MokoWaaS plugin operates as a system level extension that enforces WaaS bran
|
||||
|
||||
### Governance and Brand Owners
|
||||
|
||||
* Approve changes to WaaS terminology or visible branding
|
||||
* Approve changes to Suite terminology or visible branding
|
||||
* Review that the plugin’s behavior aligns with documented brand guidelines
|
||||
* Provide input for configuration changes that affect end user perception
|
||||
|
||||
@@ -95,7 +95,7 @@ Recommended monitoring sources:
|
||||
|
||||
* Joomla Administrator logs
|
||||
* Web server and PHP error logs
|
||||
* Centralized WaaS logging and observability tools where available
|
||||
* Centralized Suite logging and observability tools where available
|
||||
|
||||
## Maintenance Lifecycle
|
||||
|
||||
@@ -103,7 +103,7 @@ Recommended monitoring sources:
|
||||
|
||||
During planned maintenance windows:
|
||||
|
||||
* Validate that branding and terminology still match WaaS standards
|
||||
* Validate that branding and terminology still match Suite standards
|
||||
* Confirm that newly deployed templates or components do not conflict with plugin output
|
||||
* Review configuration settings to ensure they align with current policy
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
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
|
||||
NOTE: Completes the core guide set for Suite plugin governance
|
||||
-->
|
||||
|
||||
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The Rollback and Recovery Guide defines the procedures required to restore a stable operational state when the MokoWaaS plugin introduces issues or when an environment must revert to a previously validated condition. It ensures WaaS administrators, incident responders, and platform operators have a consistent and predictable process during incidents.
|
||||
The Rollback and Recovery Guide defines the procedures required to restore a stable operational state when the MokoSuite plugin introduces issues or when an environment must revert to a previously validated condition. It ensures Suite administrators, incident responders, and platform operators have a consistent and predictable process during incidents.
|
||||
|
||||
Rollback and recovery are essential components of WaaS governance, reducing downtime and ensuring branding and UI consistency across environments.
|
||||
Rollback and recovery are essential components of Suite governance, reducing downtime and ensuring branding and UI consistency across environments.
|
||||
|
||||
## When to Initiate Rollback
|
||||
|
||||
@@ -40,7 +40,7 @@ These symptoms indicate that immediate containment and structured recovery are n
|
||||
|
||||
To prevent further disruption:
|
||||
|
||||
1. Disable the MokoWaaS plugin via **System > Plugins**.
|
||||
1. Disable the MokoSuite plugin via **System > Plugins**.
|
||||
2. Clear Joomla cache.
|
||||
3. Retest impacted areas to confirm whether disabling stabilizes behavior.
|
||||
4. Review Joomla and PHP logs for indicators of root cause.
|
||||
@@ -72,7 +72,7 @@ Snapshots provide a guaranteed restoration point for complex failures.
|
||||
|
||||
Once recovery steps are complete:
|
||||
|
||||
* Ensure branding matches WaaS identity guidelines.
|
||||
* Ensure branding matches Suite identity guidelines.
|
||||
* Confirm no plugin initialization or load order errors.
|
||||
* Validate terminology strings across admin surfaces.
|
||||
* Verify stable rendering of the administrator dashboard.
|
||||
@@ -97,11 +97,11 @@ To reduce the likelihood of rollback events:
|
||||
|
||||
* Test all plugin and template updates in staging before production rollout
|
||||
* Maintain version synchronization across branding related assets
|
||||
* Acquire plugin builds only from approved WaaS release channels
|
||||
* Acquire plugin builds only from approved Suite release channels
|
||||
* Enforce strict change control and governance for branding updates
|
||||
* Audit template overrides regularly to avoid conflicts
|
||||
|
||||
These strategies improve long term WaaS platform stability.
|
||||
These strategies improve long term Suite platform stability.
|
||||
|
||||
## Revision History
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/testing-guide.md
|
||||
BRIEF: Testing guide for MokoWaaS v02.01.08
|
||||
BRIEF: Testing guide for MokoSuite v02.01.08
|
||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||
-->
|
||||
|
||||
# MokoWaaS Testing Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Testing Guide (VERSION: 02.34.52)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
1. Clean Joomla 5.x installation OR existing site with custom language overrides.
|
||||
2. Admin account with Super User access.
|
||||
3. Build the plugin package: `make package` or zip the `src/` directory.
|
||||
3. Build the plugin package: `make package` or zip the `source/` directory.
|
||||
|
||||
## 2. Test Suites
|
||||
|
||||
@@ -36,22 +36,22 @@
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Install plugin via Extensions > Install | "Installed frontend language overrides for en-GB" and "Installed administrator language overrides for en-GB" messages | [ ] |
|
||||
| 2 | Navigate to Extensions > Plugins | Plugin appears as "System - MokoWaaS" (not raw key `PLG_SYSTEM_MOKOWAAS`) | [ ] |
|
||||
| 3 | Open plugin config | Three fields visible: Brand Name (default "MokoWaaS"), Company Name (default "Moko Consulting"), Support URL (default "https://mokoconsulting.tech") | [ ] |
|
||||
| 4 | Check admin dashboard | "Welcome to MokoWaaS!" appears in control panel | [ ] |
|
||||
| 5 | Check admin footer | "Powered by MokoWaaS" appears | [ ] |
|
||||
| 6 | Check admin login page | "MokoWaaS Administrator Login" title, support links show "Moko Consulting" | [ ] |
|
||||
| 7 | Check frontend footer | "Powered by MokoWaaS" in MokoOnyx template | [ ] |
|
||||
| 8 | Check Joomla override files at `administrator/language/overrides/en-GB.override.ini` | Contains `BEGIN MokoWaaS Overrides` sentinel block | [ ] |
|
||||
| 9 | Check Joomla override files at `language/overrides/en-GB.override.ini` | Contains `BEGIN MokoWaaS Overrides` sentinel block | [ ] |
|
||||
| 2 | Navigate to Extensions > Plugins | Plugin appears as "System - MokoSuite" (not raw key `PLG_SYSTEM_MOKOSUITE`) | [ ] |
|
||||
| 3 | Open plugin config | Three fields visible: Brand Name (default "MokoSuite"), Company Name (default "Moko Consulting"), Support URL (default "https://mokoconsulting.tech") | [ ] |
|
||||
| 4 | Check admin dashboard | "Welcome to MokoSuite!" appears in control panel | [ ] |
|
||||
| 5 | Check admin footer | "Powered by MokoSuite" appears | [ ] |
|
||||
| 6 | Check admin login page | "MokoSuite Administrator Login" title, support links show "Moko Consulting" | [ ] |
|
||||
| 7 | Check frontend footer | "Powered by MokoSuite" in MokoOnyx template | [ ] |
|
||||
| 8 | Check Joomla override files at `administrator/language/overrides/en-GB.override.ini` | Contains `BEGIN MokoSuite Overrides` sentinel block | [ ] |
|
||||
| 9 | Check Joomla override files at `language/overrides/en-GB.override.ini` | Contains `BEGIN MokoSuite Overrides` sentinel block | [ ] |
|
||||
|
||||
### 2.2 Override Preservation (Install on Site with Existing Overrides)
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Before install: add a custom override `MY_CUSTOM_KEY="My Value"` to `administrator/language/overrides/en-GB.override.ini` | Override file contains custom key | [ ] |
|
||||
| 2 | Install MokoWaaS plugin | Success messages shown | [ ] |
|
||||
| 3 | Open `administrator/language/overrides/en-GB.override.ini` | `MY_CUSTOM_KEY="My Value"` still present AND MokoWaaS sentinel block appended at end | [ ] |
|
||||
| 2 | Install MokoSuite plugin | Success messages shown | [ ] |
|
||||
| 3 | Open `administrator/language/overrides/en-GB.override.ini` | `MY_CUSTOM_KEY="My Value"` still present AND MokoSuite sentinel block appended at end | [ ] |
|
||||
| 4 | In Joomla admin: System > Language Overrides | Custom override still visible and functional | [ ] |
|
||||
|
||||
### 2.3 Brand Name Configuration
|
||||
@@ -60,7 +60,7 @@
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Open plugin config, change Brand Name to "TestBrand" | Field accepts the value | [ ] |
|
||||
| 2 | Save and close plugin config | Save succeeds | [ ] |
|
||||
| 3 | Reload admin dashboard | "Welcome to TestBrand!" appears (not "MokoWaaS") | [ ] |
|
||||
| 3 | Reload admin dashboard | "Welcome to TestBrand!" appears (not "MokoSuite") | [ ] |
|
||||
| 4 | Check admin footer | "Powered by TestBrand" | [ ] |
|
||||
| 5 | Check frontend page | "Powered by TestBrand" in footer | [ ] |
|
||||
| 6 | Check Quick Icons area | "TestBrand is up to date." | [ ] |
|
||||
@@ -94,18 +94,18 @@
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Install v01.x of MokoWaaS first | Old version installed | [ ] |
|
||||
| 1 | Install v01.x of MokoSuite first | Old version installed | [ ] |
|
||||
| 2 | Install v02.01.08 over it | Upgrade succeeds with "Installed" messages | [ ] |
|
||||
| 3 | Check override files | MokoWaaS sentinel block present, no duplicate keys | [ ] |
|
||||
| 4 | Verify old inline overrides (from v01.x) are cleaned up | No stray MokoWaaS keys outside the sentinel block | [ ] |
|
||||
| 3 | Check override files | MokoSuite sentinel block present, no duplicate keys | [ ] |
|
||||
| 4 | Verify old inline overrides (from v01.x) are cleaned up | No stray MokoSuite keys outside the sentinel block | [ ] |
|
||||
|
||||
### 2.8 Uninstall
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Uninstall MokoWaaS via Extensions > Manage | "Removed frontend language overrides" and "Removed administrator language overrides" messages | [ ] |
|
||||
| 2 | Check `administrator/language/overrides/en-GB.override.ini` | MokoWaaS sentinel block removed; any custom overrides (e.g., `MY_CUSTOM_KEY`) still present | [ ] |
|
||||
| 3 | Check `language/overrides/en-GB.override.ini` | MokoWaaS block removed; file deleted if no other overrides remain | [ ] |
|
||||
| 1 | Uninstall MokoSuite via Extensions > Manage | "Removed frontend language overrides" and "Removed administrator language overrides" messages | [ ] |
|
||||
| 2 | Check `administrator/language/overrides/en-GB.override.ini` | MokoSuite sentinel block removed; any custom overrides (e.g., `MY_CUSTOM_KEY`) still present | [ ] |
|
||||
| 3 | Check `language/overrides/en-GB.override.ini` | MokoSuite block removed; file deleted if no other overrides remain | [ ] |
|
||||
| 4 | Reload admin dashboard | Default Joomla strings restored | [ ] |
|
||||
|
||||
### 2.9 Admin Override Key Coverage
|
||||
@@ -143,7 +143,7 @@ Verify the following admin areas no longer show "Joomla":
|
||||
| 3 | 404 error page | "Page Not Found" (no Joomla reference) | [ ] |
|
||||
| 4 | Frontend login support | "{company} Support" / "{brand} Documentation" | [ ] |
|
||||
|
||||
### 2.11 WaaS Master User Enforcement
|
||||
### 2.11 Suite Master User Enforcement
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
@@ -153,37 +153,37 @@ Verify the following admin areas no longer show "Joomla":
|
||||
| 4 | Remove from Super Users group, reload admin | Re-added to group | [ ] |
|
||||
| 5 | Change master_username to "customadmin" in config | Enforces new username | [ ] |
|
||||
| 6 | Set enforce_master_user to No, delete user | User NOT recreated | [ ] |
|
||||
| 7 | Check mokowaas log | Enforcement events logged | [ ] |
|
||||
| 7 | Check mokosuite log | Enforcement events logged | [ ] |
|
||||
|
||||
### 2.12 Emergency Access Two-Factor Flow
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Login as mokoconsulting with DB password | mokowaas-verify.php created in site root | [ ] |
|
||||
| 2 | Check error message | "delete /mokowaas-verify.php..." displayed | [ ] |
|
||||
| 3 | Delete mokowaas-verify.php via FTP/SSH | File removed from server | [ ] |
|
||||
| 1 | Login as mokoconsulting with DB password | mokosuite-verify.php created in site root | [ ] |
|
||||
| 2 | Check error message | "delete /mokosuite-verify.php..." displayed | [ ] |
|
||||
| 3 | Delete mokosuite-verify.php via FTP/SSH | File removed from server | [ ] |
|
||||
| 4 | Login again with same credentials | Access granted, logged in as master user | [ ] |
|
||||
| 5 | Check mokowaas-verify.flag | Cleaned up after successful login | [ ] |
|
||||
| 5 | Check mokosuite-verify.flag | Cleaned up after successful login | [ ] |
|
||||
| 6 | Check System > Action Logs | "Emergency access LOGIN" entry with IP | [ ] |
|
||||
| 7 | Check master email inbox | Notification email received with site, user, IP, time | [ ] |
|
||||
| 8 | Set `$mokowaas_allowed_ips = '1.2.3.4';` (not your IP) | Emergency login blocked | [ ] |
|
||||
| 8 | Set `$mokosuite_allowed_ips = '1.2.3.4';` (not your IP) | Emergency login blocked | [ ] |
|
||||
| 9 | Check Action Logs | "Emergency access BLOCKED (unauthorized IP)" entry | [ ] |
|
||||
| 10 | Add your IP to allowed list | Emergency login works | [ ] |
|
||||
| 11 | Remove `$mokowaas_allowed_ips` entirely | Emergency access BLOCKED (empty = denied) | [ ] |
|
||||
| 11 | Remove `$mokosuite_allowed_ips` entirely | Emergency access BLOCKED (empty = denied) | [ ] |
|
||||
| 12 | Use wrong DB password | Normal auth failure | [ ] |
|
||||
| 13 | Check Action Logs | "Emergency access FAILED (wrong password)" entry | [ ] |
|
||||
| 14 | Set emergency_access to No in plugin config | DB password login disabled | [ ] |
|
||||
| 15 | Plugin config > WaaS Access tab | IP whitelist panel shows current IPs, your IP, status | [ ] |
|
||||
| 15 | Plugin config > Suite Access tab | IP whitelist panel shows current IPs, your IP, status | [ ] |
|
||||
|
||||
### 2.13 Override Install Respects User Overrides
|
||||
|
||||
| # | Step | Expected Result | Pass |
|
||||
|---|------|-----------------|------|
|
||||
| 1 | Before install: set `TPL_ATUM_POWERED_BY="Powered by ClientCo"` | User override in file | [ ] |
|
||||
| 2 | Install MokoWaaS plugin | Success messages shown | [ ] |
|
||||
| 2 | Install MokoSuite plugin | Success messages shown | [ ] |
|
||||
| 3 | Check override file | `TPL_ATUM_POWERED_BY` still says "Powered by ClientCo" | [ ] |
|
||||
| 4 | Check MokoWaaS sentinel block | `TPL_ATUM_POWERED_BY` NOT in the block (skipped) | [ ] |
|
||||
| 5 | Check all other MokoWaaS keys | Present in the block | [ ] |
|
||||
| 4 | Check MokoSuite sentinel block | `TPL_ATUM_POWERED_BY` NOT in the block (skipped) | [ ] |
|
||||
| 5 | Check all other MokoSuite keys | Present in the block | [ ] |
|
||||
| 6 | Reinstall/update plugin | User key still preserved | [ ] |
|
||||
| 7 | Uninstall plugin | Only block keys removed, user key stays | [ ] |
|
||||
|
||||
@@ -197,7 +197,7 @@ Verify the following admin areas no longer show "Joomla":
|
||||
| 2 | Plugin config > Maintenance > Reset All Hits = Yes, save | "Reset hit counters on X articles." | [ ] |
|
||||
| 3 | Check #__content.hits | All values are 0 | [ ] |
|
||||
| 4 | Check Reset All Hits toggle | Auto-reset to No | [ ] |
|
||||
| 5 | Check mokowaas log | "All article hits reset" logged | [ ] |
|
||||
| 5 | Check mokosuite log | "All article hits reset" logged | [ ] |
|
||||
|
||||
#### 2.14b Delete All Versions
|
||||
|
||||
@@ -208,7 +208,7 @@ Verify the following admin areas no longer show "Joomla":
|
||||
| 3 | Check #__history table | Empty | [ ] |
|
||||
| 4 | Open article > Versions button | No versions shown | [ ] |
|
||||
| 5 | Check toggle | Auto-reset to No | [ ] |
|
||||
| 6 | Check mokowaas log | "All content versions purged" logged | [ ] |
|
||||
| 6 | Check mokosuite log | "All content versions purged" logged | [ ] |
|
||||
| 7 | Both toggles Yes at same time, save | Both actions execute | [ ] |
|
||||
|
||||
### 2.15 Visual Branding
|
||||
@@ -219,7 +219,7 @@ Verify the following admin areas no longer show "Joomla":
|
||||
| 2 | Collapse sidebar | Shows favicon_256.png | [ ] |
|
||||
| 3 | Log out | Login page shows logo.png | [ ] |
|
||||
| 4 | Check browser tab | favicon.svg displayed (modern) or favicon.ico (legacy) | [ ] |
|
||||
| 5 | Check /media/plg_system_mokowaas/ | All 4 image files present | [ ] |
|
||||
| 5 | Check /media/plg_system_mokosuite/ | All 4 image files present | [ ] |
|
||||
| 6 | Manually change Atum logo in template styles | Reload admin → enforced back to plugin logo | [ ] |
|
||||
| 7 | Check Atum style params in DB | logoBrandLarge, logoBrandSmall, loginLogo set, alt text empty | [ ] |
|
||||
| 8 | Set Primary Color | Admin accent color changes | [ ] |
|
||||
@@ -265,7 +265,7 @@ Verify the following admin areas no longer show "Joomla":
|
||||
|
||||
| # | Scenario | Expected Behavior |
|
||||
|---|----------|-------------------|
|
||||
| 1 | Brand Name field left empty | Falls back to default "MokoWaaS" |
|
||||
| 1 | Brand Name field left empty | Falls back to default "MokoSuite" |
|
||||
| 2 | Brand Name with special characters (`<script>`, `"`, `&`) | Characters appear escaped/safe, no XSS |
|
||||
| 3 | Very long brand name (100+ chars) | Renders without breaking layout |
|
||||
| 4 | Plugin disabled but override files exist | Sentinel block in Joomla override files still provides static branding |
|
||||
@@ -278,19 +278,19 @@ Run from the project root:
|
||||
|
||||
```bash
|
||||
# Lint all PHP files
|
||||
php -l src/script.php
|
||||
php -l src/Extension/MokoWaaS.php
|
||||
php -l source/script.php
|
||||
php -l source/Extension/MokoSuite.php
|
||||
|
||||
# Verify all override files have placeholders (no hardcoded "MokoWaaS" in values)
|
||||
grep -r '"MokoWaaS' src/language/overrides/ src/administrator/language/overrides/
|
||||
# Verify all override files have placeholders (no hardcoded "MokoSuite" in values)
|
||||
grep -r '"MokoSuite' source/language/overrides/ source/administrator/language/overrides/
|
||||
# Expected: no output (all values should use {{BRAND_NAME}})
|
||||
|
||||
# Verify sentinel constants match
|
||||
grep -c 'BLOCK_START\|BLOCK_END' src/script.php
|
||||
grep -c 'BLOCK_START\|BLOCK_END' source/script.php
|
||||
# Expected: 6+ references
|
||||
|
||||
# Verify all .ini files have version 02.01.08
|
||||
grep -r 'Version:' src/**/*.ini | grep -v '02.01.08'
|
||||
grep -r 'Version:' source/**/*.ini | grep -v '02.01.08'
|
||||
# Expected: no output
|
||||
```
|
||||
|
||||
|
||||
@@ -8,25 +8,25 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
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
|
||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin
|
||||
NOTE: Designed for administrators and Suite operations teams
|
||||
-->
|
||||
|
||||
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Troubleshooting Guide (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS Troubleshooting Guide provides a structured, repeatable approach for diagnosing and resolving issues related to branding enforcement across WaaS managed Joomla environments. It assists administrators, support engineers, and operations staff in identifying symptoms, validating root causes, and restoring consistent platform behavior.
|
||||
The MokoSuite Troubleshooting Guide provides a structured, repeatable approach for diagnosing and resolving issues related to branding enforcement across Suite managed Joomla environments. It assists administrators, support engineers, and operations staff in identifying symptoms, validating root causes, and restoring consistent platform behavior.
|
||||
|
||||
This guide focuses on actionable diagnostics, minimizing downtime, and ensuring that WaaS branding policy is applied consistently.
|
||||
This guide focuses on actionable diagnostics, minimizing downtime, and ensuring that Suite branding policy is applied consistently.
|
||||
|
||||
## Understanding the Plugin’s Operational Behavior
|
||||
|
||||
As a system level extension, the MokoWaaS plugin:
|
||||
As a system level extension, the MokoSuite plugin:
|
||||
|
||||
* Loads early in the Joomla lifecycle
|
||||
* Influences visible terminology and branding markers
|
||||
@@ -60,7 +60,7 @@ Branding appears unchanged or reverts to Joomla defaults.
|
||||
|
||||
### Missing or Incorrect Terminology
|
||||
|
||||
Labels or UI strings do not match expected WaaS terminology.
|
||||
Labels or UI strings do not match expected Suite terminology.
|
||||
|
||||
**Likely Causes:**
|
||||
|
||||
@@ -72,7 +72,7 @@ Labels or UI strings do not match expected WaaS terminology.
|
||||
|
||||
1. Validate the integrity of all language files.
|
||||
2. Check extension overrides.
|
||||
3. Reapply updated MokoWaaS language packs.
|
||||
3. Reapply updated MokoSuite language packs.
|
||||
4. Review recent Joomla updates for changes in language constants.
|
||||
|
||||
---
|
||||
@@ -130,11 +130,11 @@ If your troubleshooting steps do not resolve the issue:
|
||||
|
||||
1. Document observed symptoms and any steps already taken.
|
||||
2. Capture relevant logs, console messages, and screenshots.
|
||||
3. Escalate to WaaS operations or development teams.
|
||||
3. Escalate to Suite operations or development teams.
|
||||
4. Include environmental details such as:
|
||||
|
||||
* Joomla version
|
||||
* MokoWaaS plugin version
|
||||
* MokoSuite plugin version
|
||||
* Template version
|
||||
* Installed third party extensions
|
||||
|
||||
|
||||
@@ -8,23 +8,23 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin
|
||||
NOTE: Defines release flow, version rules, and upgrade validation
|
||||
-->
|
||||
|
||||
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.10)
|
||||
# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS Upgrade and Versioning Guide establishes a consistent lifecycle management process for the plugin across WaaS governed environments. By defining clear versioning rules, upgrade requirements, and governance commitments, this guide ensures stability and predictable branding behavior throughout the platform.
|
||||
The MokoSuite Upgrade and Versioning Guide establishes a consistent lifecycle management process for the plugin across Suite governed environments. By defining clear versioning rules, upgrade requirements, and governance commitments, this guide ensures stability and predictable branding behavior throughout the platform.
|
||||
|
||||
## Versioning Standards
|
||||
|
||||
The plugin uses a semantic versioning model aligned with WaaS operational governance. Each segment communicates functional impact and expected deployment considerations.
|
||||
The plugin uses a semantic versioning model aligned with Suite operational governance. Each segment communicates functional impact and expected deployment considerations.
|
||||
|
||||
### Version Structure
|
||||
|
||||
@@ -47,7 +47,7 @@ Before applying a new release:
|
||||
1. Validate compatibility with:
|
||||
|
||||
* Joomla core version
|
||||
* WaaS template version
|
||||
* Suite template version
|
||||
* Language pack versions
|
||||
2. Review release notes and change logs.
|
||||
3. Capture an environment snapshot or backup.
|
||||
@@ -79,7 +79,7 @@ Versioning and rollout require alignment across multiple teams.
|
||||
* Tag releases using semantic rules.
|
||||
* Provide documentation, changelogs, and upgrade notes.
|
||||
|
||||
### WaaS Platform Operations
|
||||
### Suite Platform Operations
|
||||
|
||||
* Validate releases in staging.
|
||||
* Approve and coordinate production rollout.
|
||||
|
||||
+8
-8
@@ -8,25 +8,25 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
INGROUP: MokoSuite.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
VERSION: 02.34.52
|
||||
PATH: /docs/index.md
|
||||
BRIEF: Master index of all documentation for the MokoWaaS plugin
|
||||
BRIEF: Master index of all documentation for the MokoSuite plugin
|
||||
NOTE: Automatically maintained index for all guide canvases
|
||||
-->
|
||||
|
||||
# MokoWaaS Documentation Index (VERSION: 02.32.10)
|
||||
# MokoSuite Documentation Index (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS Documentation Index provides the authoritative map of all documentation assets associated with the MokoWaaS system plugin. It ensures traceability, governance compliance, and visibility across all operational, technical, and administrative materials that support WaaS-managed Joomla environments.
|
||||
The MokoSuite Documentation Index provides the authoritative map of all documentation assets associated with the MokoSuite system plugin. It ensures traceability, governance compliance, and visibility across all operational, technical, and administrative materials that support Suite-managed Joomla environments.
|
||||
|
||||
This index serves as the entry point for contributors, administrators, and governance teams who require a single source of truth for locating and validating documentation files.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
Documentation is organized into two primary categories: core documentation and operational guides. Each file is individually versioned, governed, and maintained as part of the WaaS documentation ecosystem.
|
||||
Documentation is organized into two primary categories: core documentation and operational guides. Each file is individually versioned, governed, and maintained as part of the Suite documentation ecosystem.
|
||||
|
||||
### Core Documentation
|
||||
|
||||
@@ -55,7 +55,7 @@ Documentation is organized into two primary categories: core documentation and o
|
||||
|
||||
## Maintenance and Governance
|
||||
|
||||
To preserve documentation integrity across the WaaS platform, the following standards apply:
|
||||
To preserve documentation integrity across the Suite platform, the following standards apply:
|
||||
|
||||
* All files must include the standard Moko Consulting metadata header.
|
||||
* Version changes must be reflected both in the header and revision history.
|
||||
|
||||
+16
-16
@@ -8,27 +8,27 @@
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
INGROUP: MokoSuite
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.32.10
|
||||
BRIEF: Baseline documentation for the MokoWaaS system plugin
|
||||
VERSION: 02.34.52
|
||||
BRIEF: Baseline documentation for the MokoSuite system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.32.10)
|
||||
# MokoSuite Plugin Overview (VERSION: 02.34.52)
|
||||
|
||||
## Introduction
|
||||
|
||||
The MokoWaaS plugin is a foundational system component used across WaaS-managed Joomla environments. It ensures consistent application of platform identity, terminology, and user experience standards. By centralizing key branding functions, the plugin supports multi‑tenant WaaS operations and reduces administrative fragmentation.
|
||||
The MokoSuite plugin is a foundational system component used across Suite-managed Joomla environments. It ensures consistent application of platform identity, terminology, and user experience standards. By centralizing key branding functions, the plugin supports multi‑tenant Suite operations and reduces administrative fragmentation.
|
||||
|
||||
## Role in the WaaS Platform
|
||||
## Role in the Suite Platform
|
||||
|
||||
The plugin establishes a unified naming and branding layer across administrator and user interfaces. As the primary enforcement point for WaaS branding policy, it integrates with templates, modules, and language packs to maintain consistent terminology and presentation.
|
||||
The plugin establishes a unified naming and branding layer across administrator and user interfaces. As the primary enforcement point for Suite branding policy, it integrates with templates, modules, and language packs to maintain consistent terminology and presentation.
|
||||
|
||||
Key functions include:
|
||||
|
||||
* Replacing Joomla-native labels with WaaS-approved terminology.
|
||||
* Replacing Joomla-native labels with Suite-approved terminology.
|
||||
* Ensuring consistent visual identifiers in administrative interfaces.
|
||||
* Providing a stable branding baseline consumed by other system extensions.
|
||||
|
||||
@@ -38,7 +38,7 @@ To ensure correct operation, the plugin requires:
|
||||
|
||||
* Joomla 5.x or higher
|
||||
* PHP 8.1 or higher
|
||||
* A compatible WaaS template aligned with Moko platform standards
|
||||
* A compatible Suite template aligned with Moko platform standards
|
||||
* System-level plugin execution priority before template rendering
|
||||
|
||||
## Installation Overview
|
||||
@@ -58,12 +58,12 @@ The plugin provides configurable controls under the Joomla Plugin Manager.
|
||||
|
||||
Primary configuration categories include:
|
||||
|
||||
* **Terminology Controls:** Apply standardized WaaS vocabulary.
|
||||
* **Terminology Controls:** Apply standardized Suite vocabulary.
|
||||
* **UI Adjustments:** Modify display elements such as headers or default labels.
|
||||
* **Visibility Controls:** Suppress or replace Joomla identifiers as needed.
|
||||
* **Branding Elements:** Manage powered‑by references and footer behavior.
|
||||
|
||||
Configuration ensures a consistent and predictable WaaS identity across all managed sites.
|
||||
Configuration ensures a consistent and predictable Suite identity across all managed sites.
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
@@ -71,8 +71,8 @@ The plugin is implemented as a Joomla 5.x system plugin with the following archi
|
||||
|
||||
### Core Components
|
||||
|
||||
* **mokowaas.php** - Main plugin class (`PlgSystemMokoWaaS`) that extends `CMSPlugin`
|
||||
* **mokowaas.xml** - Plugin manifest defining metadata, file structure, and configuration parameters
|
||||
* **mokosuite.php** - Main plugin class (`PlgSystemMokoSuite`) that extends `CMSPlugin`
|
||||
* **mokosuite.xml** - Plugin manifest defining metadata, file structure, and configuration parameters
|
||||
* **services/provider.php** - Dependency injection service provider for Joomla 5.x container registration
|
||||
|
||||
### Event Handlers
|
||||
@@ -99,7 +99,7 @@ The plugin exposes the following configuration parameters:
|
||||
|
||||
### Namespace and Autoloading
|
||||
|
||||
Uses Joomla 5.x namespace: `Moko\Plugin\System\MokoWaaS` with PSR-4 autoloading through the service provider.
|
||||
Uses Joomla 5.x namespace: `Moko\Plugin\System\MokoSuite` with PSR-4 autoloading through the service provider.
|
||||
|
||||
## Operational Expectations
|
||||
|
||||
@@ -107,7 +107,7 @@ Platform operators should maintain the plugin in an enabled state at all times.
|
||||
|
||||
* Version alignment across branding components
|
||||
* Review of template overrides for conflict prevention
|
||||
* Coordination with WaaS governance for terminology changes
|
||||
* Coordination with Suite governance for terminology changes
|
||||
|
||||
## Constraints and Considerations
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# MokoWaaS Plugin Overview
|
||||
# MokoSuite Plugin Overview
|
||||
|
||||
## Executive Summary
|
||||
The MokoWaaS plugin operates as a core enablement layer within the WaaS delivery stack, aligning platform branding, terminology, and visual identity across administrative and user-facing touchpoints. It standardizes language, reinforces WaaS positioning, and reduces fragmentation risk across templates and extensions.
|
||||
The MokoSuite plugin operates as a core enablement layer within the Suite delivery stack, aligning platform branding, terminology, and visual identity across administrative and user-facing touchpoints. It standardizes language, reinforces Suite positioning, and reduces fragmentation risk across templates and extensions.
|
||||
|
||||
## Purpose
|
||||
- Replace default Joomla terminology with WaaS aligned naming.
|
||||
- Replace default Joomla terminology with Suite aligned naming.
|
||||
- Provide a consistent brand experience in the administrator interface.
|
||||
- Establish a baseline layer for future identity and UX governance.
|
||||
|
||||
@@ -17,16 +17,16 @@ The MokoWaaS plugin operates as a core enablement layer within the WaaS delivery
|
||||
## System Requirements
|
||||
- Joomla 5.x
|
||||
- PHP 8.1 or higher
|
||||
- Compatible WaaS template and language stack
|
||||
- Compatible Suite template and language stack
|
||||
- Ability to run as a system plugin before template rendering
|
||||
|
||||
## High Level Lifecycle
|
||||
1. Install the plugin via the Joomla Extension Manager.
|
||||
2. Enable the plugin in the System Plugin list.
|
||||
3. Clear cache to propagate new language strings.
|
||||
4. Validate administrator and frontend views for correct WaaS branding.
|
||||
4. Validate administrator and frontend views for correct Suite branding.
|
||||
|
||||
## Operational Notes
|
||||
- The plugin should remain enabled on all WaaS managed instances.
|
||||
- The plugin should remain enabled on all Suite managed instances.
|
||||
- Changes to terminology may impact documentation and training materials and should be coordinated with internal teams.
|
||||
- Third party extensions may require additional overrides for full branding alignment.
|
||||
|
||||
@@ -6,11 +6,11 @@ This file is part of a Moko Consulting project.
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoWaaS.Documentation
|
||||
DEFGROUP: MokoSuite.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuite
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.34.52
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
@@ -84,7 +84,7 @@ Since Joomla sites read `updates.xml` from the `main` branch, the `update-server
|
||||
|
||||
### Metadata Source
|
||||
|
||||
All metadata is extracted from the extension's XML manifest (`src/*.xml`) at build time:
|
||||
All metadata is extracted from the extension's XML manifest (`source/*.xml`) at build time:
|
||||
|
||||
| XML Element | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
@@ -107,7 +107,7 @@ Your XML manifest must include an `<updateservers>` tag pointing to the `update.
|
||||
<!-- ... -->
|
||||
<updateservers>
|
||||
<server type="extension" name="My Extension Updates">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/MokoWaaS/main/update.xml
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/MokoSuite/main/update.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
@@ -136,7 +136,7 @@ The `repo_health.yml` workflow verifies on every commit:
|
||||
- `<version>`, `<name>`, `<author>`, `<namespace>` tags present
|
||||
- Extension `type` attribute is valid
|
||||
- Language `.ini` files exist
|
||||
- `index.html` directory listing protection in `src/`, `src/admin/`, `src/site/`
|
||||
- `index.html` directory listing protection in `source/`, `source/admin/`, `source/site/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokosuite">
|
||||
<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="mokosuite.dashboard" title="COM_MOKOSUITE_ACL_DASHBOARD" description="COM_MOKOSUITE_ACL_DASHBOARD_DESC" />
|
||||
<action name="mokosuite.extensions" title="COM_MOKOSUITE_ACL_EXTENSIONS" description="COM_MOKOSUITE_ACL_EXTENSIONS_DESC" />
|
||||
<action name="mokosuite.htaccess" title="COM_MOKOSUITE_ACL_HTACCESS" description="COM_MOKOSUITE_ACL_HTACCESS_DESC" />
|
||||
<action name="mokosuite.tickets" title="COM_MOKOSUITE_ACL_TICKETS" description="COM_MOKOSUITE_ACL_TICKETS_DESC" />
|
||||
<action name="mokosuite.tickets.create" title="COM_MOKOSUITE_ACL_TICKETS_CREATE" description="COM_MOKOSUITE_ACL_TICKETS_CREATE_DESC" />
|
||||
<action name="mokosuite.tickets.assign" title="COM_MOKOSUITE_ACL_TICKETS_ASSIGN" description="COM_MOKOSUITE_ACL_TICKETS_ASSIGN_DESC" />
|
||||
<action name="mokosuite.plugins.toggle" title="COM_MOKOSUITE_ACL_PLUGINS_TOGGLE" description="COM_MOKOSUITE_ACL_PLUGINS_TOGGLE_DESC" />
|
||||
<action name="mokosuite.cache" title="COM_MOKOSUITE_ACL_CACHE" description="COM_MOKOSUITE_ACL_CACHE_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Extension catalog for MokoSuite Extension Manager.
|
||||
Each entry points to the extension's own updates.xml. The installer
|
||||
resolves the latest version and download URL at runtime, respecting
|
||||
the site's configured update channel (dev/stable).
|
||||
|
||||
To add an extension: copy an <extension> block and fill in the fields.
|
||||
-->
|
||||
<catalog>
|
||||
<extension>
|
||||
<name>MokoSuite</name>
|
||||
<element>pkg_mokosuite</element>
|
||||
<type>package</type>
|
||||
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.</description>
|
||||
<icon>icon-shield-alt</icon>
|
||||
<category>Platform</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuite-platform</article>
|
||||
<protected>true</protected>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuite/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteHQ</name>
|
||||
<element>pkg_mokosuitehq</element>
|
||||
<type>package</type>
|
||||
<description>Centralized control panel for managing all MokoSuite client installations.</description>
|
||||
<icon>icon-tachometer-alt</icon>
|
||||
<category>Platform</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuite-base</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHQ/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoOnyx</name>
|
||||
<element>mokoonyx</element>
|
||||
<type>template</type>
|
||||
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration.</description>
|
||||
<icon>icon-paint-brush</icon>
|
||||
<category>Templates</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokoonyx-template</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomOpenGraph</name>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<description>Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages.</description>
|
||||
<icon>icon-share-alt</icon>
|
||||
<category>SEO</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomopengraph</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomBackup</name>
|
||||
<element>pkg_mokojoombackup</element>
|
||||
<type>package</type>
|
||||
<description>Automated backup system with Borg integration, scheduled tasks, and remote storage.</description>
|
||||
<icon>icon-archive</icon>
|
||||
<category>Tools</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoombackup</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomHero</name>
|
||||
<element>mod_mokojoomhero</element>
|
||||
<type>module</type>
|
||||
<description>Random hero image module from a configurable folder.</description>
|
||||
<icon>icon-image</icon>
|
||||
<category>Modules</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomCommunity</name>
|
||||
<element>pkg_mokojoomcommunity</element>
|
||||
<type>package</type>
|
||||
<description>Community Builder integration package with custom fields and user management.</description>
|
||||
<icon>icon-users</icon>
|
||||
<category>Community</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomcommunity</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomCross</name>
|
||||
<element>plg_system_mokojoomcross</element>
|
||||
<type>plugin</type>
|
||||
<description>Cross-extension integration plugin for Joomla component interoperability.</description>
|
||||
<icon>icon-link</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomcross</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomStoreLocator</name>
|
||||
<element>mod_mokojoomstorelocator</element>
|
||||
<type>module</type>
|
||||
<description>Store locator module with Google Maps integration and search.</description>
|
||||
<icon>icon-map-marker-alt</icon>
|
||||
<category>Modules</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomstorelocator</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>DPCalendar API</name>
|
||||
<element>mokodpcalendarapi</element>
|
||||
<type>plugin</type>
|
||||
<description>Web Services plugin exposing DPCalendar events and calendars via REST API.</description>
|
||||
<icon>icon-calendar</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokodpcalendarapi</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>Gallery Calendar</name>
|
||||
<element>mokogallerycalendar</element>
|
||||
<type>plugin</type>
|
||||
<description>JoomGallery and DPCalendar integration — link galleries to events.</description>
|
||||
<icon>icon-images</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokogallerycalendar</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
</catalog>
|
||||
@@ -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 #__mokosuite_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_MOKOSUITE_ACL_TITLE"
|
||||
description="COM_MOKOSUITE_ACL_DESC">
|
||||
<field name="rules" type="rules"
|
||||
label="COM_MOKOSUITE_ACL_TITLE"
|
||||
validate="rules"
|
||||
filter="rules"
|
||||
component="com_mokosuite"
|
||||
section="component" />
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -0,0 +1,41 @@
|
||||
; MokoSuite Admin Dashboard - Language Strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOSUITE_DASHBOARD_TITLE="MokoSuite Control Panel"
|
||||
COM_MOKOSUITE_SITE="Site"
|
||||
COM_MOKOSUITE_DATABASE="Database"
|
||||
COM_MOKOSUITE_DEBUG_ON="Debug ON"
|
||||
COM_MOKOSUITE_OFFLINE="Offline"
|
||||
COM_MOKOSUITE_CLEAR_CACHE="Clear Cache"
|
||||
COM_MOKOSUITE_CHECK_UPDATES="Check Updates"
|
||||
COM_MOKOSUITE_ENABLED="Enabled"
|
||||
COM_MOKOSUITE_DISABLED="Disabled"
|
||||
COM_MOKOSUITE_PROTECTED="Protected"
|
||||
COM_MOKOSUITE_CONFIGURE="Configure"
|
||||
COM_MOKOSUITE_TOGGLE_SUCCESS="Plugin state updated."
|
||||
COM_MOKOSUITE_TOGGLE_FAIL="Failed to update plugin state."
|
||||
COM_MOKOSUITE_CACHE_CLEARED="Cache cleared successfully."
|
||||
COM_MOKOSUITE_EXTENSIONS_TITLE="Moko Extensions"
|
||||
COM_MOKOSUITE_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_MOKOSUITE_EXTENSIONS_LINK="Moko Extensions"
|
||||
COM_MOKOSUITE_HTACCESS_TITLE=".htaccess Maker"
|
||||
COM_MOKOSUITE_TICKETS_TITLE="Helpdesk"
|
||||
|
||||
; ACL
|
||||
COM_MOKOSUITE_ACL_DASHBOARD="View Dashboard"
|
||||
COM_MOKOSUITE_ACL_DASHBOARD_DESC="Allow viewing the MokoSuite control panel dashboard."
|
||||
COM_MOKOSUITE_ACL_EXTENSIONS="Manage Extensions"
|
||||
COM_MOKOSUITE_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
||||
COM_MOKOSUITE_ACL_HTACCESS="Manage .htaccess"
|
||||
COM_MOKOSUITE_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
|
||||
COM_MOKOSUITE_ACL_TICKETS="View Tickets"
|
||||
COM_MOKOSUITE_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
|
||||
COM_MOKOSUITE_ACL_TICKETS_CREATE="Create Tickets"
|
||||
COM_MOKOSUITE_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
|
||||
COM_MOKOSUITE_ACL_TICKETS_ASSIGN="Assign Tickets"
|
||||
COM_MOKOSUITE_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
|
||||
COM_MOKOSUITE_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
||||
COM_MOKOSUITE_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuite feature plugins."
|
||||
COM_MOKOSUITE_ACL_CACHE="Clear Cache"
|
||||
COM_MOKOSUITE_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||
@@ -0,0 +1,19 @@
|
||||
; MokoSuite Admin Dashboard - System Language Strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOSUITE="MokoSuite"
|
||||
COM_MOKOSUITE_DESCRIPTION="MokoSuite admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
|
||||
COM_MOKOSUITE_DASHBOARD_TITLE="MokoSuite Control Panel"
|
||||
COM_MOKOSUITE_MENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOSUITE_MENU_EXTENSIONS="Moko Extensions"
|
||||
COM_MOKOSUITE_MENU_PLUGINS="Feature Plugins"
|
||||
COM_MOKOSUITE_MENU_UPDATES="Joomla Updates"
|
||||
COM_MOKOSUITE_MENU_CHECKIN="Global Check-in"
|
||||
COM_MOKOSUITE_MENU_TICKETS="Helpdesk"
|
||||
COM_MOKOSUITE_MENU_HTACCESS=".htaccess Maker"
|
||||
COM_MOKOSUITE_MENU_PRIVACY="Privacy Guard"
|
||||
COM_MOKOSUITE_MENU_WAFLOG="WAF Log"
|
||||
COM_MOKOSUITE_MENU_DATABASE="Database Tools"
|
||||
COM_MOKOSUITE_MENU_CLEANUP="Cache Cleanup"
|
||||
COM_MOKOSUITE_MENU_CACHE="Cache Management"
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
@@ -20,8 +20,8 @@ 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->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuite'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuite'));
|
||||
|
||||
$container->set(
|
||||
ComponentInterface::class,
|
||||
@@ -0,0 +1,197 @@
|
||||
--
|
||||
-- MokoSuite Helpdesk Tables
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_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 `#__mokosuite_ticket_statuses` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`alias` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'bg-secondary',
|
||||
`is_default` TINYINT NOT NULL DEFAULT 0,
|
||||
`is_closed` TINYINT NOT NULL DEFAULT 0,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `#__mokosuite_ticket_statuses` (`id`, `title`, `alias`, `color`, `is_default`, `is_closed`, `ordering`) VALUES
|
||||
(1, 'Open', 'open', 'bg-primary', 1, 0, 1),
|
||||
(2, 'In Progress', 'in_progress', 'bg-info', 0, 0, 2),
|
||||
(3, 'Waiting', 'waiting', 'bg-warning text-dark', 0, 0, 3),
|
||||
(4, 'Resolved', 'resolved', 'bg-success', 0, 0, 4),
|
||||
(5, 'Closed', 'closed', 'bg-secondary', 0, 1, 5);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_priorities` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`alias` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'bg-secondary',
|
||||
`is_default` TINYINT NOT NULL DEFAULT 0,
|
||||
`weight` INT NOT NULL DEFAULT 0,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `#__mokosuite_ticket_priorities` (`id`, `title`, `alias`, `color`, `is_default`, `weight`, `ordering`) VALUES
|
||||
(1, 'Low', 'low', 'bg-secondary', 0, 10, 1),
|
||||
(2, 'Normal', 'normal', 'bg-primary', 1, 20, 2),
|
||||
(3, 'High', 'high', 'bg-warning text-dark', 0, 30, 3),
|
||||
(4, 'Urgent', 'urgent', 'bg-danger', 0, 40, 4);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_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',
|
||||
`status_id` INT UNSIGNED DEFAULT NULL,
|
||||
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
|
||||
`priority_id` INT UNSIGNED DEFAULT NULL,
|
||||
`category_id` INT UNSIGNED DEFAULT NULL,
|
||||
`contact_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_status_id` (`status_id`),
|
||||
KEY `idx_priority` (`priority`),
|
||||
KEY `idx_priority_id` (`priority_id`),
|
||||
KEY `idx_assigned` (`assigned_to`),
|
||||
KEY `idx_category` (`category_id`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_category_field_groups` (
|
||||
`category_id` INT UNSIGNED NOT NULL,
|
||||
`field_group_id` INT NOT NULL,
|
||||
PRIMARY KEY (`category_id`, `field_group_id`),
|
||||
KEY `idx_field_group` (`field_group_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_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 `#__mokosuite_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 `#__mokosuite_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;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ticket_id` INT UNSIGNED NOT NULL,
|
||||
`assignee_type` ENUM('user','group') NOT NULL DEFAULT 'user',
|
||||
`assignee_id` INT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_unique_assignment` (`ticket_id`, `assignee_type`, `assignee_id`),
|
||||
KEY `idx_ticket` (`ticket_id`),
|
||||
KEY `idx_assignee` (`assignee_type`, `assignee_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Default automation rules
|
||||
INSERT IGNORE INTO `#__mokosuite_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 `#__mokosuite_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 `#__mokosuite_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 `#__mokosuite_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 `#__mokosuite_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 `#__mokosuite_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)');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
--
|
||||
-- MokoSuite component uninstall — drop all tables
|
||||
--
|
||||
DROP TABLE IF EXISTS `#__mokosuite_download_keys`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_retention_policies`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_data_requests`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_consent_log`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_waf_log`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_ticket_automation`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_ticket_canned`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_ticket_replies`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_tickets`;
|
||||
DROP TABLE IF EXISTS `#__mokosuite_ticket_categories`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove download_keys table (feature reverted — preflight handles key preservation)
|
||||
DROP TABLE IF EXISTS `#__mokosuite_download_keys`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- RSA signing replaces key ring — drop table if it was created
|
||||
DROP TABLE IF EXISTS `#__mokosuite_api_keys`;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Add contact link to tickets (optional FK to #__contact_details)
|
||||
ALTER TABLE `#__mokosuite_tickets`
|
||||
ADD COLUMN `contact_id` INT UNSIGNED DEFAULT NULL AFTER `category_id`,
|
||||
ADD KEY `idx_contact` (`contact_id`);
|
||||
|
||||
-- Multi-assignee junction table (replaces single assigned_to column)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ticket_id` INT UNSIGNED NOT NULL,
|
||||
`assignee_type` ENUM('user','group') NOT NULL DEFAULT 'user',
|
||||
`assignee_id` INT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_unique_assignment` (`ticket_id`, `assignee_type`, `assignee_id`),
|
||||
KEY `idx_ticket` (`ticket_id`),
|
||||
KEY `idx_assignee` (`assignee_type`, `assignee_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrate existing single-assignee data to junction table
|
||||
INSERT IGNORE INTO `#__mokosuite_ticket_assignees` (`ticket_id`, `assignee_type`, `assignee_id`)
|
||||
SELECT `id`, 'user', `assigned_to` FROM `#__mokosuite_tickets` WHERE `assigned_to` IS NOT NULL AND `assigned_to` > 0;
|
||||
|
||||
-- Customizable ticket statuses (replaces ENUM)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_statuses` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`alias` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'bg-secondary',
|
||||
`is_default` TINYINT NOT NULL DEFAULT 0,
|
||||
`is_closed` TINYINT NOT NULL DEFAULT 0,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `#__mokosuite_ticket_statuses` (`id`, `title`, `alias`, `color`, `is_default`, `is_closed`, `ordering`) VALUES
|
||||
(1, 'Open', 'open', 'bg-primary', 1, 0, 1),
|
||||
(2, 'In Progress', 'in_progress', 'bg-info', 0, 0, 2),
|
||||
(3, 'Waiting', 'waiting', 'bg-warning text-dark', 0, 0, 3),
|
||||
(4, 'Resolved', 'resolved', 'bg-success', 0, 0, 4),
|
||||
(5, 'Closed', 'closed', 'bg-secondary', 0, 1, 5);
|
||||
|
||||
-- Customizable ticket priorities (replaces ENUM)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_priorities` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`alias` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'bg-secondary',
|
||||
`is_default` TINYINT NOT NULL DEFAULT 0,
|
||||
`weight` INT NOT NULL DEFAULT 0,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `#__mokosuite_ticket_priorities` (`id`, `title`, `alias`, `color`, `is_default`, `weight`, `ordering`) VALUES
|
||||
(1, 'Low', 'low', 'bg-secondary', 0, 10, 1),
|
||||
(2, 'Normal', 'normal', 'bg-primary', 1, 20, 2),
|
||||
(3, 'High', 'high', 'bg-warning text-dark', 0, 30, 3),
|
||||
(4, 'Urgent', 'urgent', 'bg-danger', 0, 40, 4);
|
||||
|
||||
-- Add INT FK columns for status/priority (coexist with ENUM during migration)
|
||||
ALTER TABLE `#__mokosuite_tickets`
|
||||
ADD COLUMN `status_id` INT UNSIGNED DEFAULT NULL AFTER `status`,
|
||||
ADD COLUMN `priority_id` INT UNSIGNED DEFAULT NULL AFTER `priority`,
|
||||
ADD KEY `idx_status_id` (`status_id`),
|
||||
ADD KEY `idx_priority_id` (`priority_id`);
|
||||
|
||||
-- Populate new columns from existing ENUM values
|
||||
UPDATE `#__mokosuite_tickets` t
|
||||
JOIN `#__mokosuite_ticket_statuses` s ON s.alias = t.status
|
||||
SET t.status_id = s.id
|
||||
WHERE t.status_id IS NULL;
|
||||
|
||||
UPDATE `#__mokosuite_tickets` t
|
||||
JOIN `#__mokosuite_ticket_priorities` p ON p.alias = t.priority
|
||||
SET t.priority_id = p.id
|
||||
WHERE t.priority_id IS NULL;
|
||||
|
||||
-- Junction: which Joomla field groups apply to which ticket categories
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_category_field_groups` (
|
||||
`category_id` INT UNSIGNED NOT NULL,
|
||||
`field_group_id` INT NOT NULL,
|
||||
PRIMARY KEY (`category_id`, `field_group_id`),
|
||||
KEY `idx_field_group` (`field_group_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,874 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'dashboard';
|
||||
|
||||
/**
|
||||
* ACL map: view name => required permission.
|
||||
*/
|
||||
private const VIEW_ACL = [
|
||||
'dashboard' => 'mokosuite.dashboard',
|
||||
'extensions' => 'mokosuite.extensions',
|
||||
'htaccess' => 'mokosuite.htaccess',
|
||||
'tickets' => 'mokosuite.tickets',
|
||||
'ticket' => 'mokosuite.tickets',
|
||||
'privacy' => 'core.admin',
|
||||
'waflog' => 'core.admin',
|
||||
'categories' => 'mokosuite.tickets',
|
||||
'canned' => 'mokosuite.tickets',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokosuite.cache',
|
||||
];
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$view = $this->input->get('view', $this->default_view);
|
||||
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
|
||||
|
||||
if (!$this->checkAcl($acl))
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Plugin toggle
|
||||
// ==================================================================
|
||||
|
||||
public function togglePlugin()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuite.plugins.toggle'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$model = $this->getModel('Dashboard');
|
||||
|
||||
$result = $model->togglePlugin(
|
||||
$app->getInput()->getInt('extension_id', 0),
|
||||
$app->getInput()->getInt('enabled', 0)
|
||||
);
|
||||
|
||||
$this->jsonResponse($result);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Heartbeat
|
||||
// ==================================================================
|
||||
|
||||
public function sendHeartbeat()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
try
|
||||
{
|
||||
$monitorPlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuite_monitor');
|
||||
|
||||
if (!$monitorPlugin)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Monitor plugin not enabled.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = new \Joomla\Registry\Registry($monitorPlugin->params);
|
||||
$baseUrl = rtrim($params->get('base_url', ''), '/');
|
||||
|
||||
// Fall back to manifest XML default if not yet saved in params
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuite_monitor/mokosuite_monitor.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
$xml = simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="base_url"]') as $field)
|
||||
{
|
||||
$baseUrl = rtrim((string) $field['default'], '/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'MokoSuiteHQ URL not configured in monitor plugin.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuite');
|
||||
$coreParams = new \Joomla\Registry\Registry($corePlugin ? $corePlugin->params : '{}');
|
||||
$healthToken = $coreParams->get('health_api_token', '');
|
||||
|
||||
if (empty($healthToken))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Health token not configured.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
|
||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||
$timestamp = time();
|
||||
|
||||
$payload = json_encode([
|
||||
'token' => $healthToken,
|
||||
'domain' => $domain,
|
||||
'site_name' => Factory::getConfig()->get('sitename', 'Joomla'),
|
||||
'site_url' => $siteUrl,
|
||||
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'timestamp' => $timestamp,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// RSA sign the request
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$signingKeyB64 = $params->get('signing_key', '');
|
||||
|
||||
// Fall back to manifest XML default if not yet saved in params
|
||||
if (empty($signingKeyB64))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuite_monitor/mokosuite_monitor.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
$xml = simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="signing_key"]') as $field)
|
||||
{
|
||||
$signingKeyB64 = (string) $field['default'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($signingKeyB64))
|
||||
{
|
||||
$privateKeyPem = base64_decode($signingKeyB64);
|
||||
$privateKey = openssl_pkey_get_private($privateKeyPem);
|
||||
|
||||
if ($privateKey !== false)
|
||||
{
|
||||
$message = $domain . '|' . $timestamp . '|' . $healthToken;
|
||||
$signature = '';
|
||||
|
||||
if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256))
|
||||
{
|
||||
$headers[] = 'X-MokoSuite-Signature: ' . base64_encode($signature);
|
||||
$headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Connection failed: ' . $error]);
|
||||
}
|
||||
elseif ($code >= 200 && $code < 300)
|
||||
{
|
||||
$body = json_decode($response, true);
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Heartbeat sent: ' . ($body['status'] ?? 'ok')]);
|
||||
}
|
||||
else
|
||||
{
|
||||
$body = json_decode($response, true);
|
||||
$this->jsonResponse(['success' => false, 'message' => 'HTTP ' . $code . ': ' . ($body['error'] ?? $body['message'] ?? 'Unknown')]);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache
|
||||
// ==================================================================
|
||||
|
||||
public function clearCache()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuite.cache'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
|
||||
}
|
||||
|
||||
public function clearTemp()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuite.cache'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Dashboard')->clearTemp());
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Extensions
|
||||
// ==================================================================
|
||||
|
||||
public function installExtension()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuite.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('mokosuite.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('mokosuite.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $this->getModel('Htaccess');
|
||||
$options = Factory::getApplication()->getInput()->getArray();
|
||||
|
||||
$model->saveOptions($options);
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode([
|
||||
'htaccess' => $model->generateHtaccess($options),
|
||||
'nginx' => $model->generateNginx($options),
|
||||
]);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Tickets
|
||||
// ==================================================================
|
||||
|
||||
public function createTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuite.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('mokosuite.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('mokosuite.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\MokoSuite\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\MokoSuite\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\MokoSuite\Administrator\Model\MaintenanceModel();
|
||||
$this->jsonResponse($model->purgeSessions());
|
||||
}
|
||||
|
||||
public function cleanDirectory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuite.cache')) { $this->jsonForbidden(); return; }
|
||||
$dirKey = Factory::getApplication()->getInput()->getString('dir_key', '');
|
||||
$model = new \Moko\Component\MokoSuite\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('mokosuite.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('#__mokosuite_ticket_categories', $data, 'id');
|
||||
} else {
|
||||
$data->ordering = 0;
|
||||
$db->insertObject('#__mokosuite_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('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuite_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('mokosuite.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('#__mokosuite_ticket_canned', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokosuite_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('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuite_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('#__mokosuite_ticket_automation', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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 MokoSuite plugin params
|
||||
$plugins = ['mokosuite', 'mokosuite_firewall', 'mokosuite_tenant', 'mokosuite_devtools', 'mokosuite_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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->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\MokoSuite\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\MokoSuite\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\MokoSuite\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\MokoSuite\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('mokosuite.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 MokoSuite 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_mokosuite'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->authorise($action, 'com_mokosuite');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\CMS\Version;
|
||||
|
||||
class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Feature plugin metadata keyed by element name.
|
||||
* Provides icon, category, and description for dashboard display.
|
||||
*/
|
||||
private const PLUGIN_META = [
|
||||
'mokosuite' => [
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'core',
|
||||
'label' => 'Core',
|
||||
'description' => 'Heartbeat, health monitoring, site aliases, extension coordination, and download key preservation.',
|
||||
'protected' => true,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokosuite_firewall' => [
|
||||
'icon' => 'icon-lock',
|
||||
'category' => 'security',
|
||||
'label' => 'Firewall',
|
||||
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokosuite_tenant' => [
|
||||
'icon' => 'icon-users',
|
||||
'category' => 'security',
|
||||
'label' => 'Tenant Restrictions',
|
||||
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokosuite_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,
|
||||
],
|
||||
'mokosuite_devtools' => [
|
||||
'icon' => 'icon-wrench',
|
||||
'category' => 'tools',
|
||||
'label' => 'Developer Tools',
|
||||
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokosuitedemo' => [
|
||||
'icon' => 'icon-undo',
|
||||
'category' => 'content',
|
||||
'label' => 'Demo Reset Task',
|
||||
'description' => 'Scheduled demo site reset with content snapshots.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokosuitesync' => [
|
||||
'icon' => 'icon-sync',
|
||||
'category' => 'content',
|
||||
'label' => 'Content Sync Task',
|
||||
'description' => 'Scheduled content synchronisation to remote MokoSuite sites.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Category display labels and colours.
|
||||
*/
|
||||
private const CATEGORIES = [
|
||||
'core' => ['label' => 'Core', 'badge' => 'bg-dark'],
|
||||
'security' => ['label' => 'Security', 'badge' => 'bg-danger'],
|
||||
'tools' => ['label' => 'Tools', 'badge' => 'bg-info'],
|
||||
'monitoring' => ['label' => 'Monitoring', 'badge' => 'bg-success'],
|
||||
'content' => ['label' => 'Content', 'badge' => 'bg-primary'],
|
||||
'api' => ['label' => 'API', 'badge' => 'bg-secondary'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Discover all installed MokoSuite plugins.
|
||||
*
|
||||
* @return array Plugin rows enriched with dashboard metadata.
|
||||
*/
|
||||
public function getFeaturePlugins(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('extension_id'),
|
||||
$db->quoteName('name'),
|
||||
$db->quoteName('element'),
|
||||
$db->quoteName('folder'),
|
||||
$db->quoteName('type'),
|
||||
$db->quoteName('enabled'),
|
||||
$db->quoteName('protected'),
|
||||
$db->quoteName('params'),
|
||||
$db->quoteName('manifest_cache'),
|
||||
])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where([
|
||||
'(' .
|
||||
// System plugins: mokosuite, mokosuite_*
|
||||
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
|
||||
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite\_%') . ')'
|
||||
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokosuite_monitor') . ')'
|
||||
// Webservices plugins
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
|
||||
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite') . ')'
|
||||
// Task plugins
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('task')
|
||||
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite%') . ')'
|
||||
. ')',
|
||||
])
|
||||
->order($db->quoteName('folder') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$plugins = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||
$version = $manifest->version ?? '';
|
||||
|
||||
// Only system plugins and task plugins match PLUGIN_META by element
|
||||
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
|
||||
? $row->element
|
||||
: $row->folder . '_' . $row->element;
|
||||
|
||||
$meta = self::PLUGIN_META[$metaKey] ?? null;
|
||||
|
||||
// Auto-generate meta for task/webservices plugins not in the map
|
||||
if (!$meta)
|
||||
{
|
||||
$meta = $this->guessPluginMeta($row);
|
||||
}
|
||||
|
||||
$categoryKey = $meta['category'] ?? 'tools';
|
||||
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
|
||||
|
||||
$plugins[] = (object) [
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'name' => $meta['label'] ?? $row->name,
|
||||
'element' => $row->element,
|
||||
'folder' => $row->folder,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'protected' => (bool) ($meta['protected'] ?? false),
|
||||
'configure_only' => (bool) ($meta['configure_only'] ?? false),
|
||||
'version' => $version,
|
||||
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
|
||||
'category' => $categoryKey,
|
||||
'categoryLabel' => $categoryInfo['label'],
|
||||
'categoryBadge' => $categoryInfo['badge'],
|
||||
'description' => $meta['description'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic site information for the info bar.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function getSiteInfo(): object
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$config = $app->getConfig();
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Get MokoSuite package version
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('manifest_cache'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuite'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
|
||||
$db->setQuery($query);
|
||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||
|
||||
return (object) [
|
||||
'sitename' => $config->get('sitename', ''),
|
||||
'joomla_version' => (new Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_type' => $db->getServerType(),
|
||||
'mokosuite_version' => $pkgCache->version ?? '—',
|
||||
'debug' => (bool) $config->get('debug'),
|
||||
'offline' => (bool) $config->get('offline'),
|
||||
'sef' => (bool) $config->get('sef'),
|
||||
'caching' => (int) $config->get('caching'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed MokoSuite component and modules with versions.
|
||||
*
|
||||
* @return array Array of extension objects with name, element, type, version.
|
||||
*/
|
||||
public function getMokoExtensions(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('element'),
|
||||
$db->quoteName('name'),
|
||||
$db->quoteName('type'),
|
||||
$db->quoteName('enabled'),
|
||||
$db->quoteName('manifest_cache'),
|
||||
])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where('('
|
||||
// The component
|
||||
. '(' . $db->quoteName('type') . ' = ' . $db->quote('component')
|
||||
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuite') . ')'
|
||||
// Admin modules
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module')
|
||||
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuite%') . ')'
|
||||
. ')')
|
||||
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$extensions = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||
|
||||
$extensions[] = (object) [
|
||||
'element' => $row->element,
|
||||
'name' => $manifest->name ?? $row->name,
|
||||
'type' => $row->type,
|
||||
'version' => $manifest->version ?? '',
|
||||
'enabled' => (int) $row->enabled,
|
||||
];
|
||||
}
|
||||
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a plugin's enabled state.
|
||||
*
|
||||
* @param int $extensionId The extension ID.
|
||||
* @param int $enabled 1 = enable, 0 = disable.
|
||||
*
|
||||
* @return array Result with success and message keys.
|
||||
*/
|
||||
public function togglePlugin(int $extensionId, int $enabled): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Verify the extension exists and is a MokoSuite plugin
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('element'), $db->quoteName('protected')])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
|
||||
$db->setQuery($query);
|
||||
$ext = $db->loadObject();
|
||||
|
||||
if (!$ext)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Extension not found.'];
|
||||
}
|
||||
|
||||
// Don't allow disabling protected/core plugins
|
||||
if (!$enabled && ((int) $ext->protected || $ext->element === 'mokosuite'))
|
||||
{
|
||||
return ['success' => false, 'message' => 'This plugin is protected and cannot be disabled.'];
|
||||
}
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = ' . ($enabled ? 1 : 0))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => $ext->element . ($enabled ? ' enabled.' : ' disabled.'),
|
||||
'enabled' => $enabled,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all Joomla caches.
|
||||
*
|
||||
* @return array Result with success and message keys.
|
||||
*/
|
||||
public function clearCache(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use Joomla's native cache API — same as com_cache
|
||||
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
|
||||
$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'))
|
||||
{
|
||||
\opcache_reset();
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'All cache cleared successfully.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Cache clear failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Joomla tmp directory.
|
||||
*
|
||||
* Removes all files and subdirectories from the configured tmp_path,
|
||||
* preserving the directory itself and any .htaccess / web.config files.
|
||||
*
|
||||
* @return array Result with success and message keys.
|
||||
*/
|
||||
public function clearTemp(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$tmpPath = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp');
|
||||
|
||||
if (!is_dir($tmpPath))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Temp directory does not exist: ' . $tmpPath];
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$protected = ['.htaccess', 'web.config', 'index.html', '.gitkeep'];
|
||||
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($tmpPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$basename = $item->getBasename();
|
||||
|
||||
// Skip protected files in the root tmp directory
|
||||
if ($item->getPath() === $tmpPath && \in_array($basename, $protected, true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->isDir())
|
||||
{
|
||||
@rmdir($item->getPathname());
|
||||
}
|
||||
else
|
||||
{
|
||||
@unlink($item->getPathname());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => sprintf('Temp directory cleaned (%d files removed).', $count)];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Temp clear failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate dashboard metadata for plugins not in the static map.
|
||||
*/
|
||||
private function guessPluginMeta(object $row): array
|
||||
{
|
||||
$meta = [
|
||||
'icon' => 'icon-puzzle-piece',
|
||||
'category' => 'tools',
|
||||
'label' => $row->name,
|
||||
'description' => '',
|
||||
'protected' => false,
|
||||
];
|
||||
|
||||
if ($row->folder === 'webservices')
|
||||
{
|
||||
$meta['icon'] = 'icon-plug';
|
||||
$meta['category'] = 'api';
|
||||
$meta['label'] = 'Web Services — ' . ucfirst($row->element);
|
||||
}
|
||||
elseif ($row->folder === 'task')
|
||||
{
|
||||
$meta['icon'] = 'icon-clock';
|
||||
$meta['category'] = 'content';
|
||||
|
||||
if (str_contains($row->element, 'sync'))
|
||||
{
|
||||
$meta['label'] = 'Content Sync Task';
|
||||
$meta['description'] = 'Scheduled content synchronisation to remote MokoSuite sites.';
|
||||
}
|
||||
elseif (str_contains($row->element, 'demo'))
|
||||
{
|
||||
$meta['label'] = 'Demo Reset Task';
|
||||
$meta['description'] = 'Scheduled demo site reset with content snapshots.';
|
||||
}
|
||||
}
|
||||
|
||||
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('#__mokosuite_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('#__mokosuite_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,80 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class ErpReportsModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getSalesReport(string $from, string $to, string $groupBy = 'month'): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$fmt = match ($groupBy) { 'day' => '%Y-%m-%d', 'week' => '%Y-W%v', 'year' => '%Y', default => '%Y-%m' };
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('DATE_FORMAT(inv.created, ' . $db->quote($fmt) . ') AS period')
|
||||
->select('COUNT(*) AS invoice_count, COALESCE(SUM(inv.total), 0) AS revenue, COALESCE(SUM(inv.amount_paid), 0) AS collected')
|
||||
->from($db->quoteName('#__mokosuite_erp_invoices', 'inv'))
|
||||
->where($db->quoteName('inv.created') . ' >= ' . $db->quote($from))
|
||||
->where($db->quoteName('inv.created') . ' <= ' . $db->quote($to . ' 23:59:59'))
|
||||
->where($db->quoteName('inv.type') . ' = ' . $db->quote('standard'))
|
||||
->group('period')->order('period ASC'));
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getTopCustomers(string $from, string $to, int $limit = 10): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('cd.name AS contact_name, COALESCE(SUM(inv.total), 0) AS total_revenue, COUNT(*) AS invoice_count')
|
||||
->from($db->quoteName('#__mokosuite_erp_invoices', 'inv'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = inv.contact_id')
|
||||
->where($db->quoteName('inv.created') . ' >= ' . $db->quote($from))
|
||||
->where($db->quoteName('inv.created') . ' <= ' . $db->quote($to . ' 23:59:59'))
|
||||
->group('inv.contact_id')->order('total_revenue DESC'), 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getTopProducts(string $from, string $to, int $limit = 10): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('p.sku, c.title AS product_name, COALESCE(SUM(ii.quantity), 0) AS qty_sold, COALESCE(SUM(ii.line_total), 0) AS revenue')
|
||||
->from($db->quoteName('#__mokosuite_erp_invoice_items', 'ii'))
|
||||
->join('INNER', $db->quoteName('#__mokosuite_erp_invoices', 'inv') . ' ON inv.id = ii.invoice_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuite_erp_products', 'p') . ' ON p.id = ii.product_id')
|
||||
->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
|
||||
->where($db->quoteName('inv.created') . ' >= ' . $db->quote($from))
|
||||
->where($db->quoteName('inv.created') . ' <= ' . $db->quote($to . ' 23:59:59'))
|
||||
->where($db->quoteName('ii.product_id') . ' IS NOT NULL')
|
||||
->group('ii.product_id')->order('revenue DESC'), 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getPipelineReport(string $from, string $to): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('status, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total_value')
|
||||
->from($db->quoteName('#__mokosuite_erp_deals'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($from))
|
||||
->where($db->quoteName('created') . ' <= ' . $db->quote($to . ' 23:59:59'))
|
||||
->group('status'));
|
||||
return $db->loadObjectList('status') ?: [];
|
||||
}
|
||||
|
||||
public function getAgingReceivables(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('inv.id, inv.ref, inv.total, inv.amount_paid, inv.due_date, (inv.total - inv.amount_paid) AS balance, DATEDIFF(CURDATE(), inv.due_date) AS days_overdue')
|
||||
->select('cd.name AS contact_name')
|
||||
->from($db->quoteName('#__mokosuite_erp_invoices', 'inv'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = inv.contact_id')
|
||||
->where($db->quoteName('inv.status') . ' IN (' . $db->quote('sent') . ',' . $db->quote('partial') . ',' . $db->quote('overdue') . ')')
|
||||
->where('(inv.total - inv.amount_paid) > 0')
|
||||
->order('days_overdue DESC'));
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
/**
|
||||
* Extension catalog model — reads catalog.xml, fetches each extension's
|
||||
* updates.xml to resolve latest version and download URL, and checks
|
||||
* local install status.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class ExtensionsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Parsed catalog entries (cached per request).
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private ?array $catalogCache = null;
|
||||
|
||||
/**
|
||||
* Get the full catalog with install status and release info.
|
||||
*
|
||||
* @return array Array of catalog entry objects
|
||||
*/
|
||||
public function getCatalog(): array
|
||||
{
|
||||
$catalog = $this->loadCatalog();
|
||||
$installed = $this->getInstalledVersions($catalog);
|
||||
$packages = [];
|
||||
|
||||
foreach ($catalog as $entry)
|
||||
{
|
||||
$release = $this->fetchFromUpdateServer($entry['updateserver'] ?? '');
|
||||
|
||||
$localVersion = $installed[$entry['element']] ?? null;
|
||||
$remoteVersion = $release['version'] ?? '';
|
||||
$downloadUrl = $release['download_url'] ?? '';
|
||||
|
||||
// Skip extensions with no release available and not installed
|
||||
if (empty($remoteVersion) && $localVersion === null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = 'not_installed';
|
||||
|
||||
if ($localVersion !== null)
|
||||
{
|
||||
$status = 'installed';
|
||||
|
||||
if ($remoteVersion !== '' && version_compare($remoteVersion, $localVersion, '>'))
|
||||
{
|
||||
$status = 'update_available';
|
||||
}
|
||||
}
|
||||
|
||||
$extensionId = $this->getExtensionId($entry['element']);
|
||||
|
||||
$needsDlid = $release['needs_dlid'] ?? false;
|
||||
$hasDlid = $needsDlid && $extensionId ? $this->hasDownloadKey($entry['element']) : false;
|
||||
|
||||
$packages[] = (object) [
|
||||
'label' => $entry['name'],
|
||||
'description' => $entry['description'],
|
||||
'element' => $entry['element'],
|
||||
'type' => $entry['type'],
|
||||
'icon' => $entry['icon'],
|
||||
'category' => $entry['category'],
|
||||
'local_version' => $localVersion ?? '',
|
||||
'remote_version' => $remoteVersion,
|
||||
'download_url' => $downloadUrl,
|
||||
'status' => $status,
|
||||
'article_url' => $entry['article'] ?? '',
|
||||
'protected' => ($entry['protected'] ?? 'false') === 'true',
|
||||
'extension_id' => $extensionId,
|
||||
'needs_dlid' => $needsDlid,
|
||||
'has_dlid' => $hasDlid,
|
||||
'has_stable' => $release['has_stable'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
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 . '/mokosuite_install_' . md5($url) . '.zip';
|
||||
|
||||
try
|
||||
{
|
||||
$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);
|
||||
|
||||
$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()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the catalog.xml file.
|
||||
*
|
||||
* @return array Array of associative arrays, one per extension
|
||||
*/
|
||||
private function loadCatalog(): array
|
||||
{
|
||||
if ($this->catalogCache !== null)
|
||||
{
|
||||
return $this->catalogCache;
|
||||
}
|
||||
|
||||
$catalogFile = JPATH_ADMINISTRATOR . '/components/com_mokosuite/catalog.xml';
|
||||
|
||||
if (!file_exists($catalogFile))
|
||||
{
|
||||
$this->catalogCache = [];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($catalogFile);
|
||||
|
||||
if (!$xml)
|
||||
{
|
||||
$this->catalogCache = [];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($xml->extension as $ext)
|
||||
{
|
||||
$entries[] = [
|
||||
'name' => (string) $ext->name,
|
||||
'element' => (string) $ext->element,
|
||||
'type' => (string) $ext->type,
|
||||
'description' => (string) $ext->description,
|
||||
'icon' => (string) $ext->icon,
|
||||
'category' => (string) $ext->category,
|
||||
'article' => (string) $ext->article,
|
||||
'protected' => (string) $ext->protected,
|
||||
'updateserver' => (string) $ext->updateserver,
|
||||
];
|
||||
}
|
||||
|
||||
$this->catalogCache = $entries;
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest version and download URL from an extension's updates.xml.
|
||||
*
|
||||
* Parses the standard Joomla update server XML format and returns
|
||||
* the highest version entry with its download URL.
|
||||
*
|
||||
* @param string $updateServerUrl URL to the updates.xml file
|
||||
*
|
||||
* @return array [version, download_url] or empty array
|
||||
*/
|
||||
private function fetchFromUpdateServer(string $updateServerUrl): array
|
||||
{
|
||||
if (empty($updateServerUrl))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$ch = curl_init($updateServerUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || empty($response))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_string($response);
|
||||
|
||||
if (!$xml)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine site's update channel preference
|
||||
$channel = 'dev'; // default to dev — show everything
|
||||
$hasStable = false;
|
||||
$hasDev = false;
|
||||
|
||||
// Find the best version entry, preferring the site's channel
|
||||
$bestVersion = '0.0.0';
|
||||
$downloadUrl = '';
|
||||
$needsDlid = false;
|
||||
|
||||
foreach ($xml->update as $update)
|
||||
{
|
||||
$ver = (string) ($update->version ?? '');
|
||||
$tag = '';
|
||||
|
||||
// Check for <tags><tag> element
|
||||
if (isset($update->tags->tag))
|
||||
{
|
||||
$tag = (string) $update->tags->tag;
|
||||
}
|
||||
|
||||
if ($tag === 'stable')
|
||||
{
|
||||
$hasStable = true;
|
||||
}
|
||||
|
||||
if ($tag === 'dev')
|
||||
{
|
||||
$hasDev = true;
|
||||
}
|
||||
|
||||
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$bestVersion = $ver;
|
||||
|
||||
if (isset($update->downloads->downloadurl))
|
||||
{
|
||||
$downloadUrl = (string) $update->downloads->downloadurl;
|
||||
|
||||
// Check if download URL contains dlid placeholder
|
||||
if (str_contains($downloadUrl, 'dlid='))
|
||||
{
|
||||
$needsDlid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestVersion === '0.0.0')
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $bestVersion,
|
||||
'download_url' => $downloadUrl,
|
||||
'has_stable' => $hasStable,
|
||||
'has_dev' => $hasDev,
|
||||
'needs_dlid' => $needsDlid,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed versions of catalog extensions.
|
||||
*
|
||||
* @param array $catalog The parsed catalog entries
|
||||
*
|
||||
* @return array element => version
|
||||
*/
|
||||
private function getInstalledVersions(array $catalog): array
|
||||
{
|
||||
if (empty($catalog))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$elements = [];
|
||||
|
||||
foreach ($catalog as $entry)
|
||||
{
|
||||
$elements[] = $db->quote($entry['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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an extension has a download key configured.
|
||||
*/
|
||||
private function hasDownloadKey(string $element): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('us.extra_query'))
|
||||
->from($db->quoteName('#__update_sites', 'us'))
|
||||
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id')
|
||||
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
|
||||
->where($db->quoteName('e.element') . ' = ' . $db->quote($element));
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$extraQuery = (string) $db->loadResult();
|
||||
|
||||
return !empty($extraQuery) && str_contains($extraQuery, 'dlid=');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension_id for an element (for uninstall links).
|
||||
*
|
||||
* @param string $element Extension element name
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
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 MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->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.mokosuite.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.mokosuite.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[] = '## MokoSuite Generated .htaccess';
|
||||
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$lines[] = '## DO NOT EDIT — regenerate from MokoSuite > .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[] = '## MokoSuite 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 MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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 MokoSuite.
|
||||
*
|
||||
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
|
||||
* and security headers — maps them to MokoSuite 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 MokoSuite.
|
||||
*/
|
||||
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('mokosuite_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 MokoSuite 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 MokoSuite 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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->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('mokosuite_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('mokosuite_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 MokoSuite import', Log::INFO, 'mokosuite');
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 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 MokoSuite import', Log::INFO, 'mokosuite');
|
||||
}
|
||||
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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->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_mokosuite'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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, 'mokosuite'),
|
||||
];
|
||||
}
|
||||
|
||||
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 MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
);
|
||||
$ticketIds = $db->loadColumn() ?: [];
|
||||
|
||||
if (!empty($ticketIds))
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuite_ticket_replies'))
|
||||
->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')')
|
||||
)->execute();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuite_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
)->execute();
|
||||
}
|
||||
|
||||
// Delete consent log
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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, 'mokosuite');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||
}
|
||||
}
|
||||
|
||||
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 #__mokosuite_data_requests WHERE status = ' . $db->quote('pending'));
|
||||
$summary->pending_requests = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokosuite_data_requests');
|
||||
$summary->total_requests = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokosuite_consent_log');
|
||||
$summary->consent_entries = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokosuite_retention_policies WHERE enabled = 1');
|
||||
$summary->policies_active = (int) $db->loadResult();
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('mokosuite_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('mokosuite_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 MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
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, 'mokosuite');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_mokosuite&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 MokoSuite';
|
||||
|
||||
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_mokosuite'))
|
||||
->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 . ' | MokoSuite 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, 'mokosuite');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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\MokoSuite\Administrator\Model\TicketsModel();
|
||||
$this->rules = $model->getAutomationRules();
|
||||
|
||||
ToolbarHelper::title('Automation Rules', 'cogs');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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 #__mokosuite_ticket_canned ORDER BY ordering ASC');
|
||||
$this->responses = $db->loadObjectList() ?: [];
|
||||
|
||||
$db->setQuery('SELECT id, title FROM #__mokosuite_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_mokosuite&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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 #__mokosuite_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_mokosuite&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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\MokoSuite\Administrator\Model\MaintenanceModel();
|
||||
$this->dirs = $model->getCleanupInfo();
|
||||
|
||||
ToolbarHelper::title('Cache & Temp Cleanup', 'trash');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\View\Dashboard;
|
||||
|
||||
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 $plugins = [];
|
||||
protected $siteInfo;
|
||||
protected $recentLogins = [];
|
||||
protected $pendingUpdates = [];
|
||||
protected $checkedOutItems = [];
|
||||
protected $wafBlocks = [];
|
||||
protected $wafChartData = [];
|
||||
protected $loginChartData = [];
|
||||
protected $mokoExtensions = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
|
||||
$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);
|
||||
$this->mokoExtensions = $model->getMokoExtensions();
|
||||
|
||||
// Check for importable Akeeba data
|
||||
try
|
||||
{
|
||||
$importModel = new \Moko\Component\MokoSuite\Administrator\Model\ImportModel();
|
||||
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
|
||||
$this->atsAvailable = $importModel->checkAtsAvailable();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->adminToolsAvailable = null;
|
||||
$this->atsAvailable = null;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
$wa->registerAndUseScript('com_mokosuite.dashboard', 'com_mokosuite/dashboard.js', [], ['defer' => true]);
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITE_DASHBOARD_TITLE'), 'cogs');
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->authorise('core.admin', 'com_mokosuite'))
|
||||
{
|
||||
ToolbarHelper::preferences('com_mokosuite');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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\MokoSuite\Administrator\Model\MaintenanceModel();
|
||||
$this->tableData = $model->getTableStatus();
|
||||
|
||||
ToolbarHelper::title('Database Tools', 'database');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\Administrator\View\ErpReports;
|
||||
|
||||
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 $activeTab = 'sales';
|
||||
protected $salesData = [];
|
||||
protected $topCustomers = [];
|
||||
protected $topProducts = [];
|
||||
protected $pipelineData = [];
|
||||
protected $agingData = [];
|
||||
protected $dateFrom;
|
||||
protected $dateTo;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$this->activeTab = $input->get('tab', 'sales', 'CMD');
|
||||
$this->dateFrom = $input->get('from', date('Y-01-01'), 'STRING');
|
||||
$this->dateTo = $input->get('to', date('Y-m-d'), 'STRING');
|
||||
$this->salesData = $model->getSalesReport($this->dateFrom, $this->dateTo);
|
||||
$this->topCustomers = $model->getTopCustomers($this->dateFrom, $this->dateTo);
|
||||
$this->topProducts = $model->getTopProducts($this->dateFrom, $this->dateTo);
|
||||
$this->pipelineData = $model->getPipelineReport($this->dateFrom, $this->dateTo);
|
||||
$this->agingData = $model->getAgingReceivables();
|
||||
ToolbarHelper::title('ERP Reports', 'icon-chart-bar');
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.erp', 'com_mokosuite/erp.css');
|
||||
$wa->registerAndUseScript('com_mokosuite.erp-dashboard', 'com_mokosuite/erp-dashboard.js', [], ['defer' => true]);
|
||||
Factory::getApplication()->getDocument()->addScriptOptions('mokosuite.erp', ['revenueChart' => $this->salesData]);
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITE_EXTENSIONS_TITLE'), 'puzzle-piece');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITE_HTACCESS_TITLE'), 'file-code');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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\MokoSuite\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_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('Privacy Guard', 'lock');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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 = [];
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
protected $customFields = [];
|
||||
protected $fieldValues = [];
|
||||
|
||||
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));
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
|
||||
// Load custom fields for this ticket's category
|
||||
if ($this->ticket && $this->ticket->category_id)
|
||||
{
|
||||
$groups = $model->getFieldGroupsForCategory((int) $this->ticket->category_id);
|
||||
$groupIds = array_column($groups, 'id');
|
||||
$this->customFields = $model->getFieldsForGroups($groupIds);
|
||||
$this->fieldValues = $model->getFieldValues($id);
|
||||
}
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect('index.php?option=com_mokosuite&view=tickets');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/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_mokosuite&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Administrator\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;
|
||||
protected $contacts = [];
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$filters = [
|
||||
'status_id' => $app->getInput()->getInt('filter_status', 0),
|
||||
'priority_id' => $app->getInput()->getInt('filter_priority', 0),
|
||||
'category_id' => $app->getInput()->getInt('filter_category', 0),
|
||||
'contact_id' => $app->getInput()->getInt('filter_contact', 0),
|
||||
];
|
||||
|
||||
$this->tickets = $model->getTickets($filters);
|
||||
$this->categories = $model->getCategories();
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
$this->statusCounts = $model->getStatusCounts();
|
||||
$this->overdue = $model->getOverdueTickets();
|
||||
$this->atsAvailable = $model->checkAtsAvailable();
|
||||
$this->contacts = $model->getContacts();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITE_TICKETS_TITLE'), 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuite\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\MokoSuite\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_mokosuite.dashboard', 'com_mokosuite/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite');
|
||||
}
|
||||
}
|
||||
@@ -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_mokosuite&task=display.saveAutomation&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json');
|
||||
$toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json');
|
||||
|
||||
$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
|
||||
?>
|
||||
|
||||
<div id="mokosuite-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_mokosuite&task=display.saveCanned&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokosuite-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_mokosuite&task=display.saveCategory&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCategory&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokosuite-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_mokosuite&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="mokosuite-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>
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
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\MokoSuite\Administrator\View\Dashboard\HtmlView $this */
|
||||
|
||||
$siteInfo = $this->siteInfo;
|
||||
$plugins = $this->plugins;
|
||||
$recentLogins = $this->recentLogins;
|
||||
$pendingUpdates = $this->pendingUpdates;
|
||||
$mokoExts = $this->mokoExtensions;
|
||||
$adminToolsAvail = $this->adminToolsAvailable ?? null;
|
||||
$atsAvail = $this->atsAvailable ?? null;
|
||||
$checkedOut = $this->checkedOutItems;
|
||||
$wafBlocks = $this->wafBlocks;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
// Group plugins by category
|
||||
$grouped = [];
|
||||
foreach ($plugins as $plugin)
|
||||
{
|
||||
$grouped[$plugin->category][] = $plugin;
|
||||
}
|
||||
|
||||
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
?>
|
||||
|
||||
<div id="mokosuite-dashboard">
|
||||
<!-- Site Info Bar -->
|
||||
<div class="mokosuite-info-bar card mb-4">
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-4">
|
||||
<div class="mokosuite-info-item">
|
||||
<span class="mokosuite-info-label"><?php echo Text::_('COM_MOKOSUITE_SITE'); ?></span>
|
||||
<span class="mokosuite-info-value fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
||||
</div>
|
||||
<div class="mokosuite-info-item">
|
||||
<span class="mokosuite-info-label">MokoSuite</span>
|
||||
<span class="mokosuite-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokosuite_version); ?></span></span>
|
||||
</div>
|
||||
<div class="mokosuite-info-item">
|
||||
<span class="mokosuite-info-label">Joomla</span>
|
||||
<span class="mokosuite-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span>
|
||||
</div>
|
||||
<div class="mokosuite-info-item">
|
||||
<span class="mokosuite-info-label">PHP</span>
|
||||
<span class="mokosuite-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->php_version); ?></span></span>
|
||||
</div>
|
||||
<div class="mokosuite-info-item">
|
||||
<span class="mokosuite-info-label"><?php echo Text::_('COM_MOKOSUITE_DATABASE'); ?></span>
|
||||
<span class="mokosuite-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span></span>
|
||||
</div>
|
||||
<?php if ($siteInfo->debug): ?>
|
||||
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOSUITE_DEBUG_ON'); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($siteInfo->offline): ?>
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOSUITE_OFFLINE'); ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="mokosuite-info-item ms-auto">
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($mokoExts)): ?>
|
||||
<!-- Moko Component & Module Versions -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<?php
|
||||
$extIcons = [
|
||||
'com_mokosuite' => 'icon-cogs',
|
||||
'mod_mokosuite_cpanel' => 'icon-tachometer-alt',
|
||||
'mod_mokosuite_menu' => 'icon-bars',
|
||||
'mod_mokosuite_cache' => 'icon-bolt',
|
||||
'mod_mokosuite_categories' => 'icon-folder',
|
||||
];
|
||||
foreach ($mokoExts as $ext):
|
||||
$icon = $extIcons[$ext->element] ?? 'icon-puzzle-piece';
|
||||
$label = str_replace(['mod_mokosuite_', 'com_mokosuite'], ['', 'Component'], $ext->element);
|
||||
$label = ucfirst($label ?: 'Component');
|
||||
?>
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 rounded border bg-white" style="font-size:0.85rem;">
|
||||
<span class="<?php echo $icon; ?>" aria-hidden="true" style="color:#1a2744;"></span>
|
||||
<span><?php echo $this->escape($label); ?></span>
|
||||
<span class="badge bg-light text-dark"><?php echo $this->escape($ext->version); ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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 MokoSuite:</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_mokosuite&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_mokosuite&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="mokosuite-btn-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Clear Cache
|
||||
</button>
|
||||
</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_mokosuite&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>
|
||||
|
||||
<!-- 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="mokosuite-category-heading mb-3">
|
||||
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
|
||||
</h3>
|
||||
<div class="mokosuite-plugin-grid row g-3 mb-4">
|
||||
<?php foreach ($catPlugins as $plugin): ?>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card mokosuite-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokosuite-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); ?> mokosuite-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 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_MOKOSUITE_PROTECTED'); ?></span>
|
||||
<?php elseif ($plugin->configure_only): ?>
|
||||
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOSUITE_ENABLED') : Text::_('COM_MOKOSUITE_DISABLED'); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input mokosuite-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_mokosuite&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_MOKOSUITE_ENABLED') : Text::_('COM_MOKOSUITE_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_MOKOSUITE_CONFIGURE'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Right: Charts & Information (4 cols) -->
|
||||
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
|
||||
|
||||
<!-- WAF Activity Chart -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><span class="icon-shield-alt" aria-hidden="true"></span> WAF Activity (14 days)</strong>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<canvas id="mokosuite-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="mokosuite-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('mokosuite-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('mokosuite-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_mokosuite&task=display.optimizeDb&format=json');
|
||||
$repairUrl = Route::_('index.php?option=com_mokosuite&task=display.repairDb&format=json');
|
||||
$purgeUrl = Route::_('index.php?option=com_mokosuite&task=display.purgeSessions&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokosuite-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,71 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
$tab = $this->activeTab;
|
||||
$totalRevenue = array_sum(array_map(fn($r) => (float) $r->revenue, $this->salesData));
|
||||
$totalCollected = array_sum(array_map(fn($r) => (float) $r->collected, $this->salesData));
|
||||
$wonDeals = $this->pipelineData['won'] ?? (object) ['cnt' => 0, 'total_value' => 0];
|
||||
$lostDeals = $this->pipelineData['lost'] ?? (object) ['cnt' => 0, 'total_value' => 0];
|
||||
$openDeals = $this->pipelineData['open'] ?? (object) ['cnt' => 0, 'total_value' => 0];
|
||||
$closedTotal = ((int) ($wonDeals->cnt ?? 0)) + ((int) ($lostDeals->cnt ?? 0));
|
||||
$winRate = $closedTotal > 0 ? round((int) ($wonDeals->cnt ?? 0) / $closedTotal * 100, 1) : 0;
|
||||
?>
|
||||
<div class="mokosuite-erp-reports">
|
||||
<div class="card shadow-sm mb-3"><div class="card-body py-2">
|
||||
<form method="get" class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<input type="hidden" name="option" value="com_mokosuite"><input type="hidden" name="view" value="erpreports"><input type="hidden" name="tab" value="<?php echo $this->escape($tab); ?>">
|
||||
<label class="form-label mb-0 small">From:</label><input type="date" name="from" class="form-control form-control-sm" style="max-width:160px" value="<?php echo $this->escape($this->dateFrom); ?>">
|
||||
<label class="form-label mb-0 small">To:</label><input type="date" name="to" class="form-control form-control-sm" style="max-width:160px" value="<?php echo $this->escape($this->dateTo); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Apply</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link <?php echo $tab === 'sales' ? 'active' : ''; ?>" href="<?php echo Route::_('index.php?option=com_mokosuite&view=erpreports&tab=sales&from=' . $this->dateFrom . '&to=' . $this->dateTo); ?>">Sales</a></li>
|
||||
<li class="nav-item"><a class="nav-link <?php echo $tab === 'pipeline' ? 'active' : ''; ?>" href="<?php echo Route::_('index.php?option=com_mokosuite&view=erpreports&tab=pipeline&from=' . $this->dateFrom . '&to=' . $this->dateTo); ?>">Pipeline</a></li>
|
||||
<li class="nav-item"><a class="nav-link <?php echo $tab === 'aging' ? 'active' : ''; ?>" href="<?php echo Route::_('index.php?option=com_mokosuite&view=erpreports&tab=aging'); ?>">Aging Receivables</a></li>
|
||||
</ul>
|
||||
<?php if ($tab === 'sales') : ?>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Revenue</div><div class="fs-3 fw-bold">$<?php echo number_format($totalRevenue, 0); ?></div></div></div></div>
|
||||
<div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Collected</div><div class="fs-3 fw-bold text-success">$<?php echo number_format($totalCollected, 0); ?></div></div></div></div>
|
||||
<div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Outstanding</div><div class="fs-3 fw-bold text-danger">$<?php echo number_format($totalRevenue - $totalCollected, 0); ?></div></div></div></div>
|
||||
</div>
|
||||
<div class="card shadow-sm mb-3"><div class="card-header"><h5 class="mb-0">Revenue by Period</h5></div><div class="card-body"><canvas id="erp-revenue-chart" height="300"></canvas></div></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Top Customers</h5></div><div class="card-body p-0"><table class="table table-sm mb-0"><thead class="table-light"><tr><th>Customer</th><th class="text-end">Revenue</th><th class="text-end">Invoices</th></tr></thead><tbody>
|
||||
<?php foreach ($this->topCustomers as $c) : ?><tr><td><?php echo $this->escape($c->contact_name ?? '—'); ?></td><td class="text-end fw-bold">$<?php echo number_format((float) $c->total_revenue, 0); ?></td><td class="text-end"><?php echo (int) $c->invoice_count; ?></td></tr><?php endforeach; ?>
|
||||
</tbody></table></div></div></div>
|
||||
<div class="col-md-6"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Top Products</h5></div><div class="card-body p-0"><table class="table table-sm mb-0"><thead class="table-light"><tr><th>Product</th><th class="text-end">Qty</th><th class="text-end">Revenue</th></tr></thead><tbody>
|
||||
<?php foreach ($this->topProducts as $p) : ?><tr><td><?php echo $this->escape($p->product_name ?? $p->sku); ?></td><td class="text-end"><?php echo number_format((float) $p->qty_sold, 0); ?></td><td class="text-end fw-bold">$<?php echo number_format((float) $p->revenue, 0); ?></td></tr><?php endforeach; ?>
|
||||
</tbody></table></div></div></div>
|
||||
</div>
|
||||
<?php elseif ($tab === 'pipeline') : ?>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Open</div><div class="fs-3 fw-bold"><?php echo (int) ($openDeals->cnt ?? 0); ?></div><div class="small">$<?php echo number_format((float) ($openDeals->total_value ?? 0), 0); ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Won</div><div class="fs-3 fw-bold text-success"><?php echo (int) ($wonDeals->cnt ?? 0); ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Lost</div><div class="fs-3 fw-bold text-danger"><?php echo (int) ($lostDeals->cnt ?? 0); ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Win Rate</div><div class="fs-3 fw-bold"><?php echo $winRate; ?>%</div></div></div></div>
|
||||
</div>
|
||||
<?php elseif ($tab === 'aging') : ?>
|
||||
<?php $buckets = ['current' => 0, '1_30' => 0, '31_60' => 0, '61_90' => 0, 'over_90' => 0];
|
||||
foreach ($this->agingData as $r) { $d = (int) $r->days_overdue; if ($d <= 0) $buckets['current'] += (float) $r->balance; elseif ($d <= 30) $buckets['1_30'] += (float) $r->balance; elseif ($d <= 60) $buckets['31_60'] += (float) $r->balance; elseif ($d <= 90) $buckets['61_90'] += (float) $r->balance; else $buckets['over_90'] += (float) $r->balance; }
|
||||
$totalAging = array_sum($buckets); ?>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">Current</div><div class="fw-bold">$<?php echo number_format($buckets['current'], 0); ?></div></div></div></div>
|
||||
<div class="col"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">1-30 Days</div><div class="fw-bold text-warning">$<?php echo number_format($buckets['1_30'], 0); ?></div></div></div></div>
|
||||
<div class="col"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">31-60</div><div class="fw-bold text-warning">$<?php echo number_format($buckets['31_60'], 0); ?></div></div></div></div>
|
||||
<div class="col"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">61-90</div><div class="fw-bold text-danger">$<?php echo number_format($buckets['61_90'], 0); ?></div></div></div></div>
|
||||
<div class="col"><div class="card shadow-sm"><div class="card-body text-center"><div class="text-muted small">90+</div><div class="fw-bold text-danger">$<?php echo number_format($buckets['over_90'], 0); ?></div></div></div></div>
|
||||
</div>
|
||||
<div class="card shadow-sm"><div class="card-body p-0"><table class="table table-sm table-hover mb-0"><thead class="table-light"><tr><th>Ref</th><th>Contact</th><th class="text-end">Total</th><th class="text-end">Paid</th><th class="text-end">Balance</th><th>Due</th><th>Days</th></tr></thead><tbody>
|
||||
<?php foreach ($this->agingData as $r) : ?>
|
||||
<tr class="<?php echo (int) $r->days_overdue > 60 ? 'table-danger' : ((int) $r->days_overdue > 30 ? 'table-warning' : ''); ?>">
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuite&view=erpinvoice&id=' . (int) $r->id); ?>" class="font-monospace"><?php echo $this->escape($r->ref); ?></a></td>
|
||||
<td><?php echo $this->escape($r->contact_name ?? '—'); ?></td><td class="text-end">$<?php echo number_format((float) $r->total, 2); ?></td>
|
||||
<td class="text-end">$<?php echo number_format((float) $r->amount_paid, 2); ?></td><td class="text-end fw-bold text-danger">$<?php echo number_format((float) $r->balance, 2); ?></td>
|
||||
<td class="small"><?php echo $this->escape($r->due_date); ?></td><td class="fw-bold"><?php echo (int) $r->days_overdue; ?></td>
|
||||
</tr><?php endforeach; ?>
|
||||
</tbody><tfoot class="table-light"><tr><td colspan="4" class="text-end fw-bold">Total</td><td class="text-end fw-bold text-danger">$<?php echo number_format($totalAging, 2); ?></td><td colspan="2"></td></tr></tfoot></table></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Moko\Component\MokoSuite\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'],
|
||||
'update_available' => ['bg-warning text-dark', 'Update Available'],
|
||||
'not_installed' => ['bg-secondary', 'Not Installed'],
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokosuite-extensions">
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOSUITE_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 <?php echo \count($pkgs) === 1 ? '' : (\count($pkgs) === 2 ? 'col-md-6' : '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>
|
||||
|
||||
<?php if (!empty($pkg->needs_dlid) && !$pkg->has_dlid && $pkg->status !== 'not_installed'): ?>
|
||||
<div class="alert alert-danger py-1 px-2 mb-2" style="font-size:0.8rem;">
|
||||
<span class="icon-exclamation-triangle" aria-hidden="true"></span>
|
||||
Download key missing — updates will fail.
|
||||
<a href="index.php?option=com_installer&view=updatesites" class="alert-link">Configure</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<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 if ($pkg->remote_version && $pkg->status === 'update_available'): ?>
|
||||
→ <?php echo htmlspecialchars($pkg->remote_version); ?>
|
||||
<?php endif; ?>
|
||||
<?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 === 'update_available'): ?>
|
||||
<button type="button" class="btn btn-sm btn-warning mokosuite-install-btn"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuite&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); ?>"
|
||||
data-needs-dlid="<?php echo $pkg->needs_dlid ? '1' : '0'; ?>"
|
||||
data-element="<?php echo htmlspecialchars($pkg->element); ?>">
|
||||
<span class="icon-refresh" aria-hidden="true"></span>
|
||||
Update to <?php echo htmlspecialchars($pkg->remote_version); ?>
|
||||
</button>
|
||||
<?php elseif ($pkg->download_url && $pkg->status === 'not_installed'): ?>
|
||||
<button type="button" class="btn btn-sm btn-primary mokosuite-install-btn"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuite&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); ?>"
|
||||
data-needs-dlid="<?php echo $pkg->needs_dlid ? '1' : '0'; ?>"
|
||||
data-element="<?php echo htmlspecialchars($pkg->element); ?>">
|
||||
<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('.mokosuite-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;
|
||||
|
||||
var needsDlid = el.dataset.needsDlid === '1';
|
||||
var dlid = '';
|
||||
|
||||
if (needsDlid) {
|
||||
dlid = prompt('Enter download key for ' + label + ':', '');
|
||||
if (dlid === null) return;
|
||||
if (!dlid.trim()) {
|
||||
Joomla.renderMessages({error: ['Download key is required for ' + label]});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirm('Install ' + label + '?')) return;
|
||||
|
||||
el.disabled = true;
|
||||
var origHtml = el.textContent;
|
||||
el.textContent = ' Installing...';
|
||||
|
||||
// Append dlid to download URL if provided
|
||||
var finalUrl = downloadUrl;
|
||||
if (dlid) {
|
||||
finalUrl += (downloadUrl.indexOf('?') !== -1 ? '&' : '?') + 'dlid=' + encodeURIComponent(dlid.trim());
|
||||
}
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('download_url', finalUrl);
|
||||
fd.append(token, '1');
|
||||
if (dlid) {
|
||||
fd.append('dlid', dlid.trim());
|
||||
fd.append('element', el.dataset.element || '');
|
||||
}
|
||||
|
||||
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 MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @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_mokosuite&task=display.saveHtaccess&format=json');
|
||||
$genUrl = Route::_('index.php?option=com_mokosuite&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="mokosuite-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.mokosuite.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, 'mokosuite-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="mokosuite-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_mokosuite&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_mokosuite">
|
||||
<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_mokosuite&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_mokosuite&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_mokosuite&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,228 @@
|
||||
<?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();
|
||||
|
||||
$statuses = $this->statuses ?? [];
|
||||
$priorities = $this->priorities ?? [];
|
||||
?>
|
||||
|
||||
<div id="mokosuite-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_mokosuite&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_mokosuite&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 $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $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
|
||||
if (!empty($t->assignees)) {
|
||||
foreach ($t->assignees as $a) {
|
||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '<span class="icon-user"></span> ';
|
||||
echo '<div>' . $icon . $this->escape($a->name) . '</div>';
|
||||
}
|
||||
} else {
|
||||
echo '<em>Unassigned</em>';
|
||||
}
|
||||
?></td></tr>
|
||||
<?php if ($t->contact_id): ?>
|
||||
<tr><td class="text-muted">Contact</td><td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id); ?>">
|
||||
<?php echo $this->escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
|
||||
</a>
|
||||
<?php if (!empty($t->contact_email)): ?><br><small><?php echo $this->escape($t->contact_email); ?></small><?php endif; ?>
|
||||
<?php if (!empty($t->contact_phone)): ?><br><small><?php echo $this->escape($t->contact_phone); ?></small><?php endif; ?>
|
||||
</td></tr>
|
||||
<?php endif; ?>
|
||||
<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 = !!empty($t->status_is_closed) && strtotime($t->sla_resolution_due) < time();
|
||||
?>
|
||||
<span class="<?php echo !empty($t->status_is_closed) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo !empty($t->status_is_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 ($statuses as $s): ?>
|
||||
<?php if ((int) $s->id !== (int) $t->status_id): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s->is_closed ? 'danger' : 'secondary'; ?> btn-status"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.updateTicketStatus&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s->id; ?>" data-token="<?php echo $token; ?>">
|
||||
<?php echo $this->escape($s->title); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<?php if (!empty($this->customFields)): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Custom Fields</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<?php foreach ($this->customFields as $field): ?>
|
||||
<tr>
|
||||
<td class="text-muted"><?php echo $this->escape($field->title); ?></td>
|
||||
<td><?php echo $this->escape($this->fieldValues[(int) $field->id] ?? '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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,297 @@
|
||||
<?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;
|
||||
$statuses = $this->statuses;
|
||||
$priorities = $this->priorities;
|
||||
$counts = $this->statusCounts;
|
||||
$overdue = $this->overdue;
|
||||
$atsAvailable = $this->atsAvailable;
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
|
||||
<div id="mokosuite-tickets">
|
||||
<!-- Status summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ($counts as $sc): ?>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo (int) $sc->cnt; ?></span><small class="text-muted"><?php echo $this->escape($sc->title); ?></small></div></div>
|
||||
<?php endforeach; ?>
|
||||
<?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_mokosuite&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_mokosuite">
|
||||
<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 ($statuses as $s): ?>
|
||||
<option value="<?php echo $s->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_status') === (int) $s->id ? 'selected' : ''; ?>><?php echo $this->escape($s->title); ?></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 ($priorities as $p): ?>
|
||||
<option value="<?php echo $p->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_priority') === (int) $p->id ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></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>Contact</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="10" 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 && empty($t->status_is_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_mokosuite&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuite&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td>
|
||||
<td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td>
|
||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
||||
<td><?php echo $t->contact_name ? '<a href="' . Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id) . '">' . $this->escape($t->contact_name) . '</a>' : '—'; ?></td>
|
||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php
|
||||
if (!empty($t->assignees)) {
|
||||
$names = [];
|
||||
foreach ($t->assignees as $a) {
|
||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '';
|
||||
$names[] = $icon . $this->escape($a->name);
|
||||
}
|
||||
echo implode(', ', $names);
|
||||
} else {
|
||||
echo '<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_mokosuite&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-4">
|
||||
<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-4">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority_id" class="form-select">
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?php echo $p->id; ?>" <?php echo $p->is_default ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Contact</label>
|
||||
<select name="contact_id" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<?php foreach ($this->contacts as $contact): ?>
|
||||
<option value="<?php echo $contact->id; ?>"><?php echo $this->escape($contact->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</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_mokosuite&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_mokosuite&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="mokosuite-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_mokosuite">
|
||||
<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_mokosuite&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_mokosuite&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_mokosuite&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_mokosuite&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_mokosuite&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_mokosuite&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>
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -16,7 +16,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Cache management API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/cache
|
||||
* POST /api/index.php/v1/mokosuite/cache
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@@ -29,7 +29,7 @@ class CacheController extends BaseController
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'cache'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -20,7 +20,7 @@ use Joomla\Registry\Registry;
|
||||
/**
|
||||
* Dashboard summary API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokowaas/dashboard
|
||||
* GET /api/index.php/v1/mokosuite/dashboard
|
||||
*
|
||||
* Returns a combined payload of site info and feature plugin states,
|
||||
* suitable for remote dashboards and monitoring.
|
||||
@@ -53,7 +53,7 @@ class DashboardController extends BaseController
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('manifest_cache'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuite'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
|
||||
$db->setQuery($query);
|
||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||
@@ -71,8 +71,8 @@ class DashboardController extends BaseController
|
||||
->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\\_%') . ')')
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite\\_%') . ')')
|
||||
->order($db->quoteName('element') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$pluginRows = $db->loadObjectList() ?: [];
|
||||
@@ -118,7 +118,7 @@ class DashboardController extends BaseController
|
||||
'site' => [
|
||||
'name' => $config->get('sitename', ''),
|
||||
'url' => rtrim(Uri::root(), '/'),
|
||||
'mokowaas_version' => $pkgCache->version ?? '',
|
||||
'mokosuite_version' => $pkgCache->version ?? '',
|
||||
'joomla_version' => (new Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_type' => $db->getServerType(),
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -16,7 +16,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Extensions list API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokowaas/extensions
|
||||
* GET /api/index.php/v1/mokosuite/extensions
|
||||
*
|
||||
* Returns all installed extensions with type, element, folder, version,
|
||||
* enabled/protected/locked status, and update server info.
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -19,9 +19,9 @@ use Joomla\Registry\Registry;
|
||||
/**
|
||||
* Health check API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokowaas/health
|
||||
* GET /api/index.php/v1/mokosuite/health
|
||||
*
|
||||
* Returns full health diagnostics from the MokoWaaS system plugin.
|
||||
* Returns full health diagnostics from the MokoSuite system plugin.
|
||||
* Requires a Joomla API token with core.manage permissions.
|
||||
*
|
||||
* @since 1.0.0
|
||||
@@ -46,11 +46,11 @@ class HealthController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
|
||||
if (!$plugin)
|
||||
{
|
||||
$this->sendJson(503, ['error' => 'MokoWaaS system plugin not enabled']);
|
||||
$this->sendJson(503, ['error' => 'MokoSuite system plugin not enabled']);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class HealthController extends BaseController
|
||||
'caching' => (bool) $config->get('caching', 0),
|
||||
],
|
||||
'plugin' => [
|
||||
'brand' => $params->get('brand_name', 'MokoWaaS'),
|
||||
'brand' => $params->get('brand_name', 'MokoSuite'),
|
||||
'company' => $params->get('company_name', 'Moko Consulting'),
|
||||
],
|
||||
];
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -17,7 +17,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Extension install-from-URL API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/install
|
||||
* POST /api/index.php/v1/mokosuite/install
|
||||
* Body: {"url": "https://example.com/path/to/extension.zip"}
|
||||
*
|
||||
* Downloads a ZIP from the given URL and installs it via Joomla's Installer.
|
||||
@@ -42,7 +42,7 @@ class InstallController extends BaseController
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'install'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
@@ -115,7 +115,7 @@ class InstallController extends BaseController
|
||||
{
|
||||
$config = Factory::getConfig();
|
||||
$tmpPath = $config->get('tmp_path', JPATH_ROOT . '/tmp');
|
||||
$zipFile = $tmpPath . '/mokowaas_install_' . bin2hex(random_bytes(8)) . '.zip';
|
||||
$zipFile = $tmpPath . '/mokosuite_install_' . bin2hex(random_bytes(8)) . '.zip';
|
||||
|
||||
// Download
|
||||
$this->downloadFile($url, $zipFile);
|
||||
@@ -123,7 +123,7 @@ class InstallController extends BaseController
|
||||
try
|
||||
{
|
||||
// Extract
|
||||
$extractDir = $tmpPath . '/mokowaas_extract_' . bin2hex(random_bytes(8));
|
||||
$extractDir = $tmpPath . '/mokosuite_extract_' . bin2hex(random_bytes(8));
|
||||
|
||||
if (!mkdir($extractDir, 0755, true))
|
||||
{
|
||||
@@ -207,7 +207,7 @@ class InstallController extends BaseController
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_FAILONERROR => true,
|
||||
CURLOPT_USERAGENT => 'MokoWaaS-Installer/1.0',
|
||||
CURLOPT_USERAGENT => 'MokoSuite-Installer/1.0',
|
||||
]);
|
||||
|
||||
$success = curl_exec($ch);
|
||||
+14
-14
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -16,15 +16,15 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Feature plugins API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokowaas/plugins — list MokoWaaS plugins + status
|
||||
* POST /api/index.php/v1/mokowaas/plugins/toggle — enable/disable a feature plugin
|
||||
* GET /api/index.php/v1/mokosuite/plugins — list MokoSuite plugins + status
|
||||
* POST /api/index.php/v1/mokosuite/plugins/toggle — enable/disable a feature plugin
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class PluginsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* List all MokoWaaS feature plugins with their enabled state.
|
||||
* List all MokoSuite feature plugins with their enabled state.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -57,14 +57,14 @@ class PluginsController extends BaseController
|
||||
'(' .
|
||||
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
|
||||
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\\_%') . '))'
|
||||
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite\\_%') . '))'
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
|
||||
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') . ')'
|
||||
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('mokosuite') . ')'
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('task')
|
||||
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas%') . ')'
|
||||
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuite%') . ')'
|
||||
. ')',
|
||||
])
|
||||
->order($db->quoteName('folder') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||
@@ -98,13 +98,13 @@ class PluginsController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a MokoWaaS feature plugin on or off.
|
||||
* Toggle a MokoSuite feature plugin on or off.
|
||||
*
|
||||
* Expects JSON body: {"extension_id": 123, "enabled": true}
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'plugins'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
@@ -130,7 +130,7 @@ class PluginsController extends BaseController
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Verify the extension exists and is a MokoWaaS plugin
|
||||
// Verify the extension exists and is a MokoSuite plugin
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('element'), $db->quoteName('protected')])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
@@ -147,7 +147,7 @@ class PluginsController extends BaseController
|
||||
}
|
||||
|
||||
// Don't allow disabling protected/core plugins
|
||||
if (!$enabled && ((int) $ext->protected || $ext->element === 'mokowaas'))
|
||||
if (!$enabled && ((int) $ext->protected || $ext->element === 'mokosuite'))
|
||||
{
|
||||
$this->sendJson(409, ['error' => 'This plugin is protected and cannot be disabled']);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Provision reset API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuite/provision-reset
|
||||
*
|
||||
* Resets a site for new client provisioning: clears hits, versions,
|
||||
* download keys, and flags the site for fresh client info collection.
|
||||
* Used after copying a demo site to create a new client install.
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class ProvisionController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Reset the site for new client provisioning.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function execute($task = 'provision'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuite'))
|
||||
{
|
||||
$this->sendJson(403, ['error' => 'Not authorized']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->input->getMethod() !== 'POST')
|
||||
{
|
||||
$this->sendJson(405, ['error' => 'POST required']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$results = [];
|
||||
|
||||
// 1. Reset article hit counters
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
)->execute();
|
||||
$results['hits_reset'] = $db->getAffectedRows();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$results['hits_reset'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// 2. Delete content version history
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)->delete($db->quoteName('#__history'))
|
||||
)->execute();
|
||||
$results['versions_deleted'] = $db->getAffectedRows();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$results['versions_deleted'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// 3. Regenerate heartbeat token if requested
|
||||
$input = $app->getInput()->json;
|
||||
$resetToken = (bool) ($input->get('reset_token', false, 'BOOLEAN'));
|
||||
|
||||
if ($resetToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
|
||||
$plugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuite');
|
||||
|
||||
if ($plugin)
|
||||
{
|
||||
$pluginParams = new \Joomla\Registry\Registry($plugin->params);
|
||||
$pluginParams->set('health_api_token', $newToken);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($pluginParams->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuite'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
|
||||
$results['token_regenerated'] = true;
|
||||
$results['new_token'] = $newToken;
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$results['token_regenerated'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reset all user API tokens if requested
|
||||
$resetApiTokens = (bool) ($input->get('reset_api_tokens', false, 'BOOLEAN'));
|
||||
|
||||
if ($resetApiTokens)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get users who have API tokens before deleting
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||
->from($db->quoteName('#__user_keys'))
|
||||
->where($db->quoteName('series') . ' LIKE ' . $db->quote('api-%'))
|
||||
);
|
||||
$affectedUserIds = $db->loadColumn() ?: [];
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)->delete($db->quoteName('#__user_keys'))
|
||||
->where($db->quoteName('series') . ' LIKE ' . $db->quote('api-%'))
|
||||
)->execute();
|
||||
$results['api_tokens_revoked'] = $db->getAffectedRows();
|
||||
|
||||
// Notify affected users
|
||||
if (!empty($affectedUserIds))
|
||||
{
|
||||
$this->notifyTokenReset($db, $affectedUserIds);
|
||||
$results['users_notified'] = \count($affectedUserIds);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$results['api_tokens_revoked'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Flag site for fresh client info setup
|
||||
try
|
||||
{
|
||||
// Write a flag file that the core plugin checks on next admin load
|
||||
$flagFile = JPATH_ADMINISTRATOR . '/cache/mokosuite_setup_required.flag';
|
||||
file_put_contents($flagFile, json_encode([
|
||||
'created' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'reason' => 'provision-reset',
|
||||
'remote_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
]));
|
||||
$results['setup_flag'] = true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$results['setup_flag'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'message' => 'Site provisioned for new client.',
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users that their API tokens have been revoked.
|
||||
*/
|
||||
private function notifyTokenReset($db, array $userIds): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('name'), $db->quoteName('email')])
|
||||
->from($db->quoteName('#__users'))
|
||||
->whereIn($db->quoteName('id'), $userIds)
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
);
|
||||
$users = $db->loadObjectList() ?: [];
|
||||
|
||||
$config = Factory::getConfig();
|
||||
$siteName = $config->get('sitename', 'Joomla');
|
||||
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
|
||||
foreach ($users as $u)
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer->clearAllRecipients();
|
||||
$mailer->addRecipient($u->email, $u->name);
|
||||
$mailer->setSubject($siteName . ' — API tokens have been reset');
|
||||
$mailer->setBody(
|
||||
"Hello {$u->name},\n\n"
|
||||
. "Your API access tokens on {$siteName} have been revoked by an administrator.\n\n"
|
||||
. "If you use API integrations, please log in and generate a new token:\n"
|
||||
. "{$siteUrl}/administrator/\n\n"
|
||||
. "— {$siteName}"
|
||||
);
|
||||
$mailer->send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
private function sendJson(int $code, array $data): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Remote login API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuite/remote-login
|
||||
* Body: {"token": "health_api_token", "user": "requesting_username", "origin": "MokoSuiteHQ"}
|
||||
*
|
||||
* Validates the health API token, generates a one-time login token
|
||||
* for the master user, and returns a URL that auto-authenticates.
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class RemoteLoginController extends BaseController
|
||||
{
|
||||
/**
|
||||
* One-time token validity in seconds.
|
||||
*/
|
||||
private const OTL_TTL = 60;
|
||||
|
||||
/**
|
||||
* Generate a one-time login URL for the master user.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function execute($task = 'remoteLogin'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput()->json;
|
||||
|
||||
$token = $input->get('token', '', 'RAW');
|
||||
$origin = $input->get('origin', '', 'STRING');
|
||||
|
||||
if (empty($token))
|
||||
{
|
||||
$this->sendJson(401, ['error' => 'Missing token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate against the core plugin's health_api_token
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
|
||||
if (!$plugin)
|
||||
{
|
||||
$this->sendJson(503, ['error' => 'MokoSuite core plugin not found']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = new Registry($plugin->params);
|
||||
$healthToken = $params->get('health_api_token', '');
|
||||
|
||||
if (empty($healthToken) || !hash_equals($healthToken, $token))
|
||||
{
|
||||
$this->sendJson(401, ['error' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the master user
|
||||
$masterUsernames = $this->getMasterUsernames($params);
|
||||
|
||||
if (empty($masterUsernames))
|
||||
{
|
||||
$this->sendJson(403, ['error' => 'No master user configured']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the first master username
|
||||
$masterUsername = $masterUsernames[0];
|
||||
|
||||
// Look up the user
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('username')])
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('username') . ' = ' . $db->quote($masterUsername))
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
);
|
||||
$user = $db->loadObject();
|
||||
|
||||
if (!$user)
|
||||
{
|
||||
$this->sendJson(403, ['error' => 'Master user not found or blocked']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate one-time login token
|
||||
$otlToken = bin2hex(random_bytes(32));
|
||||
$expires = time() + self::OTL_TTL;
|
||||
|
||||
// Store in a temp file (avoids DB schema changes)
|
||||
$otlFile = JPATH_ADMINISTRATOR . '/cache/mokosuite_otl_' . md5($otlToken) . '.json';
|
||||
file_put_contents($otlFile, json_encode([
|
||||
'token' => $otlToken,
|
||||
'user_id' => (int) $user->id,
|
||||
'username' => $user->username,
|
||||
'expires' => $expires,
|
||||
'origin' => substr($origin, 0, 100),
|
||||
]));
|
||||
|
||||
// Build login URL
|
||||
$loginUrl = rtrim(Uri::root(), '/') . '/administrator/index.php?mokosuite_otl=' . $otlToken;
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'login_url' => $loginUrl,
|
||||
'expires' => $expires,
|
||||
'user' => $user->username,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode master usernames from plugin params.
|
||||
*
|
||||
* @param Registry $params Plugin params.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getMasterUsernames(Registry $params): array
|
||||
{
|
||||
// Use MokoSuiteHelper if available
|
||||
$helperFile = JPATH_PLUGINS . '/system/mokosuite/Helper/MokoSuiteHelper.php';
|
||||
|
||||
if (file_exists($helperFile))
|
||||
{
|
||||
require_once $helperFile;
|
||||
|
||||
if (method_exists(\Moko\Plugin\System\MokoSuite\Helper\MokoSuiteHelper::class, 'getMasterUsernames'))
|
||||
{
|
||||
return \Moko\Plugin\System\MokoSuite\Helper\MokoSuiteHelper::getMasterUsernames();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response and terminate.
|
||||
*
|
||||
* @param int $code HTTP status code.
|
||||
* @param array $data Response data.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function sendJson(int $code, array $data): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -18,7 +18,7 @@ use Joomla\Registry\Registry;
|
||||
/**
|
||||
* Demo site reset API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/reset
|
||||
* POST /api/index.php/v1/mokosuite/reset
|
||||
* Body: {"baseline": "default"}
|
||||
*
|
||||
* Restores the site to a named baseline snapshot.
|
||||
@@ -35,7 +35,7 @@ class ResetController extends BaseController
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'reset'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
@@ -53,11 +53,11 @@ class ResetController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
|
||||
if (!$plugin)
|
||||
{
|
||||
$this->sendJson(503, ['error' => 'MokoWaaS system plugin not enabled']);
|
||||
$this->sendJson(503, ['error' => 'MokoSuite system plugin not enabled']);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,24 +84,24 @@ class ResetController extends BaseController
|
||||
*
|
||||
* @param Registry $params Plugin parameters
|
||||
*
|
||||
* @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService
|
||||
* @return \Moko\Plugin\System\MokoSuite\Service\DemoResetService
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private function createService(Registry $params)
|
||||
{
|
||||
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php';
|
||||
$serviceFile = JPATH_PLUGINS . '/task/mokosuitedemo/src/Service/DemoResetService.php';
|
||||
|
||||
if (!file_exists($serviceFile))
|
||||
{
|
||||
throw new \RuntimeException('DemoResetService not found — is the MokoWaaS plugin installed?');
|
||||
throw new \RuntimeException('DemoResetService not found — is the demo reset plugin installed?');
|
||||
}
|
||||
|
||||
require_once $serviceFile;
|
||||
|
||||
$media = (bool) $params->get('demo_snapshot_include_media', 1);
|
||||
|
||||
return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media);
|
||||
return new \Moko\Plugin\Task\MokoSuiteDemo\Service\DemoResetService($media);
|
||||
}
|
||||
|
||||
/**
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -18,8 +18,8 @@ use Joomla\Registry\Registry;
|
||||
/**
|
||||
* Snapshot management API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokowaas/snapshot — list snapshots
|
||||
* POST /api/index.php/v1/mokowaas/snapshot — create snapshot
|
||||
* GET /api/index.php/v1/mokosuite/snapshot — list snapshots
|
||||
* POST /api/index.php/v1/mokosuite/snapshot — create snapshot
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
@@ -68,7 +68,7 @@ class SnapshotController extends BaseController
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'snapshot'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
@@ -88,7 +88,7 @@ class SnapshotController extends BaseController
|
||||
|
||||
try
|
||||
{
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
$params = $plugin ? new Registry($plugin->params) : new Registry;
|
||||
|
||||
$body = json_decode($app->input->json->getRaw(), true);
|
||||
@@ -112,27 +112,27 @@ class SnapshotController extends BaseController
|
||||
/**
|
||||
* Create DemoResetService from plugin params.
|
||||
*
|
||||
* @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService
|
||||
* @return \Moko\Plugin\System\MokoSuite\Service\DemoResetService
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private function createService()
|
||||
{
|
||||
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php';
|
||||
$serviceFile = JPATH_PLUGINS . '/task/mokosuitedemo/src/Service/DemoResetService.php';
|
||||
|
||||
if (!file_exists($serviceFile))
|
||||
{
|
||||
throw new \RuntimeException('DemoResetService not found');
|
||||
throw new \RuntimeException('DemoResetService not found — is the demo reset plugin installed?');
|
||||
}
|
||||
|
||||
require_once $serviceFile;
|
||||
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
$params = $plugin ? new Registry($plugin->params) : new Registry;
|
||||
|
||||
$media = (bool) $params->get('demo_snapshot_include_media', 1);
|
||||
|
||||
return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media);
|
||||
return new \Moko\Plugin\Task\MokoSuiteDemo\Service\DemoResetService($media);
|
||||
}
|
||||
|
||||
/**
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -18,7 +18,7 @@ use Joomla\Registry\Registry;
|
||||
/**
|
||||
* Content sync trigger API controller (sender side).
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/sync
|
||||
* POST /api/index.php/v1/mokosuite/sync
|
||||
*
|
||||
* Pushes content to all configured sync targets.
|
||||
*
|
||||
@@ -26,7 +26,7 @@ use Joomla\Registry\Registry;
|
||||
*/
|
||||
class SyncController extends BaseController
|
||||
{
|
||||
public function execute(): void
|
||||
public function execute($task = 'sync'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
@@ -44,11 +44,11 @@ class SyncController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuite');
|
||||
|
||||
if (!$plugin)
|
||||
{
|
||||
$this->sendJson(503, ['error' => 'MokoWaaS system plugin not enabled']);
|
||||
$this->sendJson(503, ['error' => 'MokoSuite system plugin not enabled']);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,10 @@ class SyncController extends BaseController
|
||||
$params = new Registry($plugin->params);
|
||||
$targets = json_decode($params->get('sync_targets', '[]'), true) ?: [];
|
||||
|
||||
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncService.php';
|
||||
$serviceFile = JPATH_PLUGINS . '/task/mokosuitesync/src/Service/ContentSyncService.php';
|
||||
require_once $serviceFile;
|
||||
|
||||
$service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService();
|
||||
$service = new \Moko\Plugin\Task\MokoSuiteSync\Service\ContentSyncService();
|
||||
$result = $service->syncAllTargets($targets);
|
||||
|
||||
$this->sendJson(200, $result);
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -16,7 +16,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Content sync receiver API controller (target side).
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/sync-receive
|
||||
* POST /api/index.php/v1/mokosuite/sync-receive
|
||||
*
|
||||
* Accepts a JSON payload from a source site and applies the content locally.
|
||||
*
|
||||
@@ -24,7 +24,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
*/
|
||||
class SyncReceiveController extends BaseController
|
||||
{
|
||||
public function execute(): void
|
||||
public function execute($task = 'syncReceive'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
@@ -46,16 +46,16 @@ class SyncReceiveController extends BaseController
|
||||
{
|
||||
$payload = json_decode($app->input->json->getRaw(), true);
|
||||
|
||||
if (empty($payload['mokowaas_sync']))
|
||||
if (empty($payload['mokosuite_sync']))
|
||||
{
|
||||
$this->sendJson(400, ['error' => 'Invalid payload — missing mokowaas_sync version']);
|
||||
$this->sendJson(400, ['error' => 'Invalid payload — missing mokosuite_sync version']);
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncReceiver.php';
|
||||
$serviceFile = JPATH_PLUGINS . '/task/mokosuitesync/src/Service/ContentSyncReceiver.php';
|
||||
require_once $serviceFile;
|
||||
|
||||
$receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver();
|
||||
$receiver = new \Moko\Plugin\Task\MokoSuiteSync\Service\ContentSyncReceiver();
|
||||
$result = $receiver->receive($payload);
|
||||
|
||||
$this->sendJson(200, $result);
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -16,7 +16,7 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
/**
|
||||
* Update check API controller.
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/update
|
||||
* POST /api/index.php/v1/mokosuite/update
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@@ -29,7 +29,7 @@ class UpdateController extends BaseController
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function execute(): void
|
||||
public function execute($task = 'update'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
+18
-18
@@ -1,50 +1,50 @@
|
||||
/**
|
||||
* MokoWaaS Dashboard Styles
|
||||
* @package com_mokowaas
|
||||
* MokoSuite Dashboard Styles
|
||||
* @package com_mokosuite
|
||||
*/
|
||||
|
||||
/* Info bar */
|
||||
.mokowaas-info-bar .card-body {
|
||||
.mokosuite-info-bar .card-body {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.mokowaas-info-item {
|
||||
.mokosuite-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mokowaas-info-label {
|
||||
.mokosuite-info-label {
|
||||
font-size: 0.8125rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.mokowaas-info-value {
|
||||
.mokosuite-info-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Plugin cards */
|
||||
.mokowaas-plugin-card {
|
||||
.mokosuite-plugin-card {
|
||||
transition: box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
border-left: 3px solid #0d6efd;
|
||||
}
|
||||
|
||||
.mokowaas-plugin-card:hover {
|
||||
.mokosuite-plugin-card:hover {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mokowaas-plugin-disabled {
|
||||
.mokosuite-plugin-disabled {
|
||||
opacity: 0.6;
|
||||
border-left-color: #adb5bd;
|
||||
}
|
||||
|
||||
.mokowaas-plugin-disabled:hover {
|
||||
.mokosuite-plugin-disabled:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mokowaas-plugin-icon {
|
||||
.mokosuite-plugin-icon {
|
||||
font-size: 1.5rem;
|
||||
color: #1a2744;
|
||||
width: 2rem;
|
||||
@@ -52,37 +52,37 @@
|
||||
}
|
||||
|
||||
/* Category headings */
|
||||
.mokowaas-category-heading {
|
||||
.mokosuite-category-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.mokowaas-toggle {
|
||||
.mokosuite-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mokowaas-toggle:disabled {
|
||||
.mokosuite-toggle:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.mokowaas-quick-actions .btn {
|
||||
.mokosuite-quick-actions .btn {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mokowaas-quick-actions .btn:disabled {
|
||||
.mokosuite-quick-actions .btn:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Loading spinner overlay on toggle */
|
||||
.mokowaas-plugin-card.is-loading {
|
||||
.mokosuite-plugin-card.is-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mokowaas-plugin-card.is-loading::after {
|
||||
.mokosuite-plugin-card.is-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* MokoSuite+ERP Customer Portal styles
|
||||
* @since 02.34.16
|
||||
*/
|
||||
|
||||
.mokosuite-portal h2,
|
||||
.mokosuite-portal-orders h2,
|
||||
.mokosuite-portal-invoices h2,
|
||||
.mokosuite-portal-license h2 {
|
||||
color: #1a2744;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Signing page */
|
||||
.mokosuite-sign-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#signature-canvas {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Verification page */
|
||||
.mokosuite-verify-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Portal cards */
|
||||
.mokosuite-portal .card {
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.mokosuite-portal .card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
+30
-8
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* MokoWaaS Dashboard Scripts
|
||||
* @package com_mokowaas
|
||||
* MokoSuite Dashboard Scripts
|
||||
* @package com_mokosuite
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
'use strict';
|
||||
|
||||
// Plugin toggle switches
|
||||
document.querySelectorAll('.mokowaas-toggle').forEach(function (toggle) {
|
||||
document.querySelectorAll('.mokosuite-toggle').forEach(function (toggle) {
|
||||
toggle.addEventListener('change', function () {
|
||||
var checkbox = this;
|
||||
var card = checkbox.closest('.mokowaas-plugin-card');
|
||||
var card = checkbox.closest('.mokosuite-plugin-card');
|
||||
var extensionId = checkbox.dataset.extensionId;
|
||||
var url = checkbox.dataset.url;
|
||||
var token = checkbox.dataset.token;
|
||||
@@ -35,11 +35,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success) {
|
||||
card.classList.toggle('mokowaas-plugin-disabled', !enabled);
|
||||
card.classList.toggle('mokosuite-plugin-disabled', !enabled);
|
||||
if (label) {
|
||||
label.textContent = enabled
|
||||
? Joomla.Text._('COM_MOKOWAAS_ENABLED') || 'Enabled'
|
||||
: Joomla.Text._('COM_MOKOWAAS_DISABLED') || 'Disabled';
|
||||
? Joomla.Text._('COM_MOKOSUITE_ENABLED') || 'Enabled'
|
||||
: Joomla.Text._('COM_MOKOSUITE_DISABLED') || 'Disabled';
|
||||
}
|
||||
} else {
|
||||
// Revert on failure
|
||||
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
|
||||
// Clear cache button
|
||||
var cacheBtn = document.getElementById('mokowaas-btn-cache');
|
||||
var cacheBtn = document.getElementById('mokosuite-btn-cache');
|
||||
if (cacheBtn) {
|
||||
cacheBtn.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
@@ -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 MokoSuite? 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; });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* MokoSuite+ERP Signature Pad — HTML5 Canvas drawing for e-signature capture.
|
||||
* Touch-friendly, works on mobile/tablet/desktop.
|
||||
* @since 02.34.16
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
'use strict';
|
||||
|
||||
var canvas = document.getElementById('signature-canvas');
|
||||
if (!canvas) { return; }
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
var drawing = false;
|
||||
var hasSigned = false;
|
||||
|
||||
// High-DPI support
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#000';
|
||||
|
||||
function getPos(e) {
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var touch = e.touches ? e.touches[0] : e;
|
||||
return { x: touch.clientX - r.left, y: touch.clientY - r.top };
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function (e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
|
||||
canvas.addEventListener('mousemove', function (e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSigned = true; });
|
||||
canvas.addEventListener('mouseup', function () { drawing = false; });
|
||||
canvas.addEventListener('mouseleave', function () { drawing = false; });
|
||||
|
||||
canvas.addEventListener('touchstart', function (e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }, { passive: false });
|
||||
canvas.addEventListener('touchmove', function (e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSigned = true; }, { passive: false });
|
||||
canvas.addEventListener('touchend', function () { drawing = false; });
|
||||
|
||||
// Clear
|
||||
var clearBtn = document.getElementById('clear-signature');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function () {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hasSigned = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Submit
|
||||
var form = document.getElementById('signing-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!hasSigned) {
|
||||
alert('Please draw your signature before submitting.');
|
||||
return;
|
||||
}
|
||||
|
||||
var consentBox = document.getElementById('consent-checkbox');
|
||||
if (consentBox && !consentBox.checked) {
|
||||
alert('You must accept the e-signature consent agreement.');
|
||||
return;
|
||||
}
|
||||
|
||||
var token = form.dataset.token;
|
||||
var signatureData = canvas.toDataURL('image/png');
|
||||
var basePath = (Joomla.getOptions('system.paths') || {}).baseFull || '';
|
||||
|
||||
var body = {
|
||||
token: token,
|
||||
signature: signatureData,
|
||||
signature_type: 'draw'
|
||||
};
|
||||
|
||||
// Geolocation
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
body.geo_lat = pos.coords.latitude;
|
||||
body.geo_lon = pos.coords.longitude;
|
||||
submitSignature(basePath, body);
|
||||
}, function () {
|
||||
submitSignature(basePath, body);
|
||||
}, { timeout: 5000 });
|
||||
} else {
|
||||
submitSignature(basePath, body);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function submitSignature(basePath, body) {
|
||||
var btn = document.getElementById('btn-sign');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
|
||||
// If consent needed, send consent first
|
||||
var consentBox = document.getElementById('consent-checkbox');
|
||||
var consentPromise = Promise.resolve();
|
||||
|
||||
if (consentBox) {
|
||||
consentPromise = fetch(basePath + 'api/index.php/v1/mokosuite/erp/esign/public', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: body.token, accepted: true, action: 'consent' })
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
consentPromise.then(function () {
|
||||
return fetch(basePath + 'api/index.php/v1/mokosuite/erp/esign/public', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
if (result.ok) {
|
||||
document.querySelector('.mokosuite-sign-page').textContent = '';
|
||||
var success = document.createElement('div');
|
||||
success.className = 'alert alert-success fs-5 text-center py-5';
|
||||
success.textContent = 'Document signed successfully. Thank you!';
|
||||
document.querySelector('.mokosuite-sign-page').appendChild(success);
|
||||
} else {
|
||||
alert(result.error || 'Signing failed. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign Document';
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Network error: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign Document';
|
||||
});
|
||||
}
|
||||
|
||||
// Decline
|
||||
var declineBtn = document.getElementById('btn-decline');
|
||||
if (declineBtn) {
|
||||
declineBtn.addEventListener('click', function () {
|
||||
var reason = prompt('Reason for declining (optional):');
|
||||
if (reason === null) { return; }
|
||||
|
||||
var token = form.dataset.token;
|
||||
var basePath = (Joomla.getOptions('system.paths') || {}).baseFull || '';
|
||||
|
||||
fetch(basePath + 'api/index.php/v1/mokosuite/erp/esign/public', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token, reason: reason, action: 'decline' })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
document.querySelector('.mokosuite-sign-page').textContent = '';
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'alert alert-warning fs-5 text-center py-5';
|
||||
msg.textContent = 'Document declined.';
|
||||
document.querySelector('.mokosuite-sign-page').appendChild(msg);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
FILE INFORMATION
|
||||
DEFGROUP: Joomla.Component
|
||||
INGROUP: MokoSuite
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||
VERSION: 02.34.16
|
||||
PATH: /mokosuite.xml
|
||||
BRIEF: Component manifest for MokoSuite admin dashboard and REST API
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuite</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.52-dev</version>
|
||||
<description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoSuite</namespace>
|
||||
|
||||
<install>
|
||||
<sql><file driver="mysql" charset="utf8">sql/install.mysql.sql</file></sql>
|
||||
</install>
|
||||
<uninstall>
|
||||
<sql><file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file></sql>
|
||||
</uninstall>
|
||||
<update>
|
||||
<schemas>
|
||||
<schemapath type="mysql">sql/updates/mysql</schemapath>
|
||||
</schemas>
|
||||
</update>
|
||||
|
||||
<administration>
|
||||
<menu img="class:cogs">MokoSuite</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokosuite" img="class:cogs">COM_MOKOSUITE_MENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokosuite&view=extensions" img="class:puzzle-piece">COM_MOKOSUITE_MENU_EXTENSIONS</menu>
|
||||
<menu link="option=com_mokosuite&view=tickets" img="class:headphones">COM_MOKOSUITE_MENU_TICKETS</menu>
|
||||
<menu link="option=com_mokosuite&view=htaccess" img="class:file-code">COM_MOKOSUITE_MENU_HTACCESS</menu>
|
||||
<menu link="option=com_mokosuite&view=privacy" img="class:lock">COM_MOKOSUITE_MENU_PRIVACY</menu>
|
||||
<menu link="option=com_mokosuite&view=waflog" img="class:shield-alt">COM_MOKOSUITE_MENU_WAFLOG</menu>
|
||||
<menu link="option=com_mokosuite&view=database" img="class:database">COM_MOKOSUITE_MENU_DATABASE</menu>
|
||||
<menu link="option=com_mokosuite&view=cleanup" img="class:trash">COM_MOKOSUITE_MENU_CLEANUP</menu>
|
||||
<menu link="option=com_plugins&filter[folder]=system&filter[search]=mokosuite" img="class:power-off">COM_MOKOSUITE_MENU_PLUGINS</menu>
|
||||
<menu link="option=com_installer&view=update" img="class:refresh">COM_MOKOSUITE_MENU_UPDATES</menu>
|
||||
<menu link="option=com_checkin" img="class:check-square">COM_MOKOSUITE_MENU_CHECKIN</menu>
|
||||
<menu link="option=com_cache" img="class:bolt">COM_MOKOSUITE_MENU_CACHE</menu>
|
||||
</submenu>
|
||||
<files folder="admin">
|
||||
<filename>access.xml</filename>
|
||||
<filename>catalog.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_mokosuite.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>
|
||||
</files>
|
||||
</api>
|
||||
|
||||
<media destination="com_mokosuite" folder="media">
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
</media>
|
||||
</extension>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user