diff --git a/.gitignore b/.gitignore index ee502562..2a61bd13 100644 --- a/.gitignore +++ b/.gitignore @@ -107,7 +107,7 @@ replit.md *.tar.gz *.tgz *.zip -!src/payload/*.zip +!source/payload/*.zip artifacts/ release/ releases/ @@ -122,6 +122,7 @@ build/ dist/ out/ 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 diff --git a/.gitmodules b/.gitmodules index f6b1e884..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +0,0 @@ -[submodule "src/packages/tpl_mokoonyx"] - path = src/packages/tpl_mokoonyx - url = https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx.git - branch = main diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md index ea146a19..abba657b 100644 --- a/.mokogitea/CLAUDE.md +++ b/.mokogitea/CLAUDE.md @@ -1,316 +1,82 @@ - +| Field | Value | +|---|---| +| **Package** | `pkg_mokowaas` | +| **Language** | PHP 8.1+ | +| **Branch** | develop on `dev`, merge to `main` (protected) | +| **Wiki** | [MokoWaaS Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/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/` | -> | `{{EXTENSION_NAME}}` | The `` element in `manifest.xml` at the repository root | -> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) | -> | `{{EXTENSION_ELEMENT}}` | The `` 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 - - * - * 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 - -``` +### Feature Plugins +- `plg_system_mokowaas_firewall` β€” WAF, IP blocklist, security headers, password policy +- `plg_system_mokowaas_tenant` β€” admin restrictions for non-master users +- `plg_system_mokowaas_devtools` β€” dev mode, hit reset, version cleanup, download key reset +- `plg_system_mokowaas_offline` β€” offline mode bypass for legal pages +- `plg_system_mokowaas_monitor` β€” Grafana heartbeat registration -**YAML / Shell / XML:** Use the appropriate comment syntax with the same fields. JSON files are exempt. +### Component (`com_mokowaas`) +- Admin dashboard with plugin management, WAF charts, extension catalog +- Helpdesk ticketing system +- REST API controllers ---- +### Modules +- `mod_mokowaas_cpanel` β€” admin dashboard widget +- `mod_mokowaas_menu` β€” admin sidebar menu +- `mod_mokowaas_cache` β€” status bar cache/temp cleaner +- `mod_mokowaas_categories` β€” auto-category tree menu -## Version Management +### Task Plugins +- `plg_task_mokowaasdemo` β€” scheduled demo site reset +- `plg_task_mokowaassync` β€” scheduled content sync +- `plg_task_mokowaas_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 `` 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_mokowaas.xml` β€” package manifest +- `source/script.php` β€” install script +- `source/packages/` β€” all sub-extensions -```xml - -01.02.04 +## Rules - - - - {{EXTENSION_NAME}} - 01.02.04 - - - https://github.com/mokoconsulting-tech/MokoWaaS/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip - - - - - - -``` +- **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 - - - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml - - -``` - -**License Key (Download Key):** -- MokoGitea's endpoint validates license keys passed as `?dlid=MOKO-XXXX-XXXX-XXXX-XXXX` -- The generated XML includes `` 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 `` response - -**Rules:** -- Do NOT create or commit a static `updates.xml` β€” MokoGitea generates it from releases -- The `` in release tags must match `` in the manifest and `README.md` -- Release assets (ZIPs) must be attached to git releases β€” MokoGitea uses them for `` -- `` β€” 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/`). -- `` tag must be kept in sync with `README.md` version and `updates.xml`. -- Must include `` block pointing to this repo's `updates.xml`. -- Must include `` and `` sections. -- Joomla 4.x requires `Moko\{{EXTENSION_NAME}}` 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: `(): ` β€” imperative, lower-case subject, no trailing period. - -Valid types: `feat` Β· `fix` Β· `docs` Β· `chore` Β· `ci` Β· `refactor` Β· `style` Β· `test` Β· `perf` Β· `revert` Β· `build` - ---- - -## Branch Naming - -Format: `/[/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/.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 diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 30237d02..57839505 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -9,7 +9,7 @@ Package - MokoWaaS MokoConsulting White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments - 02.33.00 + 02.34.16 GNU General Public License v3 @@ -21,6 +21,6 @@ PHP package - src/ + source/ diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml index 33aff715..def55e40 100644 --- a/.mokogitea/workflows/auto-bump.yml +++ b/.mokogitea/workflows/auto-bump.yml @@ -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: | diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 44a2d64a..8fa46848 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -1,285 +1,316 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Release -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform -# PATH: /templates/workflows/universal/auto-release.yml.template -# VERSION: 05.00.00 -# BRIEF: Universal build & release οΏ½ detects platform from manifest.xml -# -# +========================================================================+ -# | UNIVERSAL BUILD & RELEASE PIPELINE | -# +========================================================================+ -# | | -# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | -# | | -# | Platform-specific: | -# | joomla: XML manifest, updates.xml, type-prefixed packages | -# | dolibarr: mod*.class.php, update.txt, dev version reset | -# | generic: README-only, no update stream | -# | | -# +========================================================================+ - -name: "Universal: Build & Release" - -on: - pull_request: - types: [opened, closed] - branches: - - main - workflow_dispatch: - inputs: - action: - description: 'Action to perform' - required: false - type: choice - default: release - options: - - release - - promote-rc - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} - GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} - -permissions: - contents: write - -jobs: - # ── PR Opened β†’ Rename branch to RC and build RC release ───────────────────── - promote-rc: - name: Promote to RC - runs-on: release - if: >- - (github.event.action == 'opened' && github.event.pull_request.merged != true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 1 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - run: | - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - # Always fetch latest CLI tools β€” never use stale cache from previous runs - rm -rf /tmp/moko-platform-api - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - name: Rename branch to rc - run: | - php /tmp/moko-platform-api/cli/branch_rename.php \ - --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ - --pr "${{ github.event.pull_request.number }}" - - - name: Checkout rc and configure git - run: | - git fetch origin rc - git checkout rc - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - - - name: Publish RC release - run: | - php /tmp/moko-platform-api/cli/release_publish.php \ - --path . --stability rc --bump minor --branch rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --skip-update-stream - - - name: Summary - if: always() - run: | - echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY - echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY - - # ── Merged PR β†’ Build & Release (or promote RC to stable) ──────────────────── - release: - name: Build & Release Pipeline - runs-on: release - if: >- - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 0 - - - name: Configure git for bot pushes - run: | - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - - - name: Check for merge conflict markers - run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) - if [ -n "$CONFLICTS" ]; then - echo "::error::Merge conflict markers found β€” aborting release" - echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "No conflict markers found" - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' - run: | - # Ensure PHP + Composer are available - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - # Always fetch latest CLI tools β€” never use stale cache from previous runs - rm -rf /tmp/moko-platform-api - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - - name: "Publish stable release" - run: | - php /tmp/moko-platform-api/cli/release_publish.php \ - --path . --stability stable --bump minor --branch main \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --skip-update-stream - - # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- - - name: "Step 9: Mirror release to GitHub" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_MIRROR_TOKEN != '' - continue-on-error: true - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_mirror.php \ - --version "$VERSION" --tag "$RELEASE_TAG" \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ - --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ - --branch main 2>&1 || true - echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY - - # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- - - name: "Step 10: Push main to GitHub mirror" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_MIRROR_TOKEN != '' - continue-on-error: true - run: | - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) - GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) - git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ - git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" - git fetch origin main --depth=1 - git push github origin/main:refs/heads/main --force 2>/dev/null \ - && echo "main branch pushed to GitHub mirror" \ - || echo "WARNING: GitHub mirror push failed" - - - name: "Step 11: Delete rc branch and recreate dev from main" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - - # Delete rc branch (ephemeral β€” created by promote-rc) - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/branches/rc" 2>/dev/null \ - && echo "Deleted rc branch" || echo "rc branch not found" - - # Delete dev branch - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" - - # Recreate dev from main (now includes version bump + changelog promotion) - curl -sf -X POST -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - "${API_BASE}/branches" \ - -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" - - echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY - - - name: "Step 12: Create version branch from main" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - BRANCH_NAME="version/${VERSION}" - MAIN_SHA=$(git rev-parse HEAD) - - # Delete old version branch if it exists (same version re-release) - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" - - # Create version/XX.YY.ZZ from main - curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" - - echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY - - - - # -- Dolibarr post-release: Reset dev version ----------------------------- - - name: "Post-release: Reset dev version" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/version_reset_dev.php \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ - --branch dev --path . 2>&1 || true - - # -- Summary -------------------------------------------------------------- - - name: Pipeline Summary - if: always() - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - PLATFORM="${{ steps.platform.outputs.platform }}" - if [ "${{ steps.version.outputs.skip }}" = "true" ]; then - echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY - echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY - elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then - echo "## Already Released β€” ${VERSION}" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY - echo "|------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY - fi +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/auto-release.yml.template +# VERSION: 05.00.00 +# BRIEF: Universal build & release οΏ½ detects platform from manifest.xml +# +# +========================================================================+ +# | UNIVERSAL BUILD & RELEASE PIPELINE | +# +========================================================================+ +# | | +# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | +# | | +# | Platform-specific: | +# | joomla: XML manifest, updates.xml, type-prefixed packages | +# | dolibarr: mod*.class.php, update.txt, dev version reset | +# | generic: README-only, no update stream | +# | | +# +========================================================================+ + +name: "Universal: Build & Release" + +on: + pull_request: + types: [opened, closed] + branches: + - main + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: false + type: choice + default: release + options: + - release + - promote-rc + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + # ── PR Opened β†’ Rename branch to RC and build RC release ───────────────────── + promote-rc: + name: Promote to RC + runs-on: release + if: >- + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" + + - name: Rename branch to rc + run: | + php ${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}" \ + --pr "${{ github.event.pull_request.number }}" + + - name: Checkout rc and configure git + run: | + git fetch origin rc + git checkout rc + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Publish RC release + run: | + php ${MOKO_CLI}/release_publish.php \ + --path . --stability rc --bump minor --branch rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + - name: Summary + if: always() + run: | + echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY + echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY + + # ── Merged PR β†’ Build & Release (or promote RC to stable) ──────────────────── + release: + name: Build & Release Pipeline + runs-on: release + if: >- + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 0 + + - name: Configure git for bot pushes + run: | + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found - aborting release" + echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' + run: | + 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 + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" + + - name: "Publish stable release" + run: | + php ${MOKO_CLI}/release_publish.php \ + --path . --stability stable --bump minor --branch main \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + - name: Update release notes from CHANGELOG.md + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Extract [Unreleased] section from changelog + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + [ -z "$NOTES" ] && NOTES="Stable release" + else + NOTES="Stable release" + fi + + # Update release body via API + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "Release notes updated from CHANGELOG.md" + fi + + # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- + - name: "Step 9: Mirror release to GitHub" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${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" \ + --branch main 2>&1 || true + echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY + + # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- + - name: "Step 10: Push main to GitHub mirror" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) + GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) + git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ + git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" + git fetch origin main --depth=1 + git push github origin/main:refs/heads/main --force 2>/dev/null \ + && echo "main branch pushed to GitHub mirror" \ + || echo "WARNING: GitHub mirror push failed" + + - name: "Step 11: Delete rc branch and recreate dev from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Delete rc branch (ephemeral - created by promote-rc) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/rc" 2>/dev/null \ + && echo "Deleted rc branch" || echo "rc branch not found" + + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" + + # Recreate dev from main (now includes version bump + changelog promotion) + curl -sf -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" + + echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY + + - name: "Step 12: Create version branch from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + BRANCH_NAME="version/${VERSION}" + MAIN_SHA=$(git rev-parse HEAD) + + # Delete old version branch if it exists (same version re-release) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" + + # Create version/XX.YY.ZZ from main + curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" + + echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY + + + + # -- Dolibarr post-release: Reset dev version ----------------------------- + - name: "Post-release: Reset dev version" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/version_reset_dev.php \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ + --branch dev --path . 2>&1 || true + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + if: always() + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + PLATFORM="${{ steps.platform.outputs.platform }}" + if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY + echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then + echo "## Already Released - ${VERSION}" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index d2e729db..efd283bb 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation -# VERSION: 02.33.00 +# VERSION: 02.34.16 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index 4d78d7a4..aa31cc72 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -295,6 +295,30 @@ 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: | diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 780150d3..1a9eeef0 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -63,16 +63,22 @@ 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 + # 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/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; 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 + 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” 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 @@ -96,20 +102,23 @@ jobs: release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; esac - # Read current version (bump already handled by push workflow) - VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) - [ -z "$VERSION" ] && VERSION="00.00.01" + # Bump version via CLI: patch for dev/alpha/beta, minor for RC + case "$STABILITY" in + release-candidate) BUMP="minor" ;; + *) BUMP="patch" ;; + esac - # Strip any existing suffix from version before applying stability + php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true + + # Set stability suffix and verify consistency + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') php ${MOKO_CLI}/version_set_platform.php \ --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true - - # Verify version consistency across all files php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true - # Update VERSION variable with suffix + # Append suffix for output if [ -n "$SUFFIX" ]; then VERSION="${VERSION}${SUFFIX}" fi @@ -155,19 +164,39 @@ jobs: --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --repo "${GITEA_REPO}" --branch dev --prerelease - - name: Ensure prerelease flag + - 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}" - # Get release ID by tag and force prerelease=true - RELEASE_ID=$(curl -s "${API_BASE}/releases/tags/${TAG}" \ - -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" | jq -r '.id // empty') + + # 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 - curl -s -X PATCH "${API_BASE}/releases/${RELEASE_ID}" \ - -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"prerelease": true}' - echo "Marked release ${TAG} (id=${RELEASE_ID}) as prerelease" + 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 @@ -181,55 +210,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 diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml deleted file mode 100644 index ac5c9a52..00000000 --- a/.mokogitea/workflows/update-server.yml +++ /dev/null @@ -1,302 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# 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] " - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1118c62f..6ce54691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,58 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./CHANGELOG.md - VERSION: 02.33.00 + VERSION: 02.34.16 BRIEF: Version history using `Keep a Changelog` --> -# Changelog## [02.32.00] - 2026-06-02 +# Changelog + +## [Unreleased] + +### Added +- Database Tools view β€” table status, optimize, repair, session purge (#127) +- Cache Cleanup view β€” directory size reporting and one-click cleanup (#128) +- mod_mokowaas_cache β€” one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner) +- mod_mokowaas_menu β€” collapsible admin sidebar menu using native MetisMenu classes (like Community Builder) +- SSL certificate expiry monitoring in cpanel module (#148) +- MokoWaaS-specific update badge (blue) separate from other updates in cpanel module +- migrateUpdateServerUrls() β€” rewrites all Moko extension update server URLs to clean /updates.xml on install/update +- fixMenuIcons() β€” sets menu_icon params on submenu items (Joomla only renders img on level 1) +- setupCacheModule() β€” registers cache cleaner module in status bar position on install +- Component config.xml for Joomla Options modal (#149) +- preflight() ALTER for #__extensions.element default (MySQL strict mode fix) +- Retire MokoJoomTOS, MokoATS-Automation, MokoDPCalendarAPI, MokoGalleryCalendar on install +- MokoJoomTOS settings auto-migrate to mokowaas_offline before removal +- dev-release and pre-release workflows with changelog extraction into release notes +- RC pre-release consolidates dev patches into clean minor version bump + + +### Changed +- 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 `` 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 `Unassigned` HTML instead of italic text +- Cache cleaner CSRF failure β€” token now sent as POST FormData +- Admin menu icons missing for Helpdesk and .htaccess Maker +- Firewall install error "Field 'element' doesn't have a default value" (MySQL strict mode) + + +## [02.32] - 2026-06-02 + ### Added - Admin control panel dashboard in com_mokowaas with site info bar, feature plugin grid, and quick actions - Feature plugin architecture β€” MokoWaaS features split into toggleable plugins managed from the dashboard @@ -42,7 +89,8 @@ - License key validation (licensing system not ready β€” will return in future release) - Dynamic MokoGitea update feed dependency (replaced with static updates.xml) -## [02.31.00] - 2026-06-01 +## [02.31] - 2026-06-01 + ### Added - License key support via Joomla's native Update Sites download key system (dlid) - Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint @@ -75,7 +123,8 @@ - Site Aliases config tab (hardcoded to dev.{primary_domain}) - File sync (images/, files/, media/) β€” sync is API/DB content only -## [02.29.03] - 2026-05-31 +## [02.29] - 2026-05-31 + ### Added - `allow_extension_updates` param β€” separate update rights from installer restrictions; tenants can update extensions by default even when the installer is restricted - Hardcoded master usernames β€” multiple privileged users supported with identical access @@ -89,7 +138,6 @@ - Demo Mode with configurable warning banner on frontend when enabled -### Fixed - Demo banner countdown now shows weeks/days/months for longer intervals instead of raw hours - `DemoResetService` β€” baseline snapshot and restore for DB tables + media files - API endpoints `POST /?mokowaas=reset` and `POST /?mokowaas=snapshot` (query-string) @@ -104,6 +152,4 @@ - Package installer: clean up legacy `mokowaasbrand` extension entries and files on install/update - API endpoint `GET /?mokowaas=extensions` and `GET /api/v1/mokowaas/extensions` β€” list installed extensions with version, status, and update server info -## [02.20.00] --- 2026-05-28 - -## [02.20.00] --- 2026-05-28 +## [02.20] --- 2026-05-28 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 26547caa..00000000 --- a/CLAUDE.md +++ /dev/null @@ -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) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3a0a46aa..a0892a85 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: ./CODE_OF_CONDUCT.md BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default --> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf60cc5c..f0957582 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,30 @@ The version tools update all files containing version stamps: Files synced from other repos (with a `# REPO:` header) are not touched. +## Changelog + +We use [Keep a Changelog](https://keepachangelog.com/) with an `[Unreleased]` staging section. + +### Rules + +- All changes go under `## [Unreleased]` β€” this is the "current work" section +- Entries stay under `[Unreleased]` until a **stable release** merges to `main` +- On stable release, `[Unreleased]` entries are promoted to a version heading (e.g., `## [02.34] - 2026-06-10`) +- Only **minor versions** get changelog headings β€” patch numbers from dev are never shown +- Dev/alpha/beta/RC pre-release descriptions pull from `[Unreleased]` automatically +- **CI will block PRs to main** if `[Unreleased]` has no entries + +### Categories + +Use these headings under each version: + +- `### Added` β€” new features +- `### Changed` β€” changes to existing functionality +- `### Deprecated` β€” features that will be removed +- `### Removed` β€” features that were removed +- `### Fixed` β€” bug fixes +- `### Security` β€” vulnerability fixes + ## Code Standards - **PHP**: PSR-12, tabs for indentation diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 8ea124af..24afc444 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoWaaSBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand --> diff --git a/LICENSE.md b/LICENSE.md index 1197c9f5..206a207e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./LICENSE.md - VERSION: 02.33.00 + VERSION: 02.34.16 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 0eba6c43..0b00d788 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /README.md BRIEF: MokoWaaS platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 6ba5df75..fd3f006e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME] INGROUP: [PROJECT_NAME].Documentation REPO: [REPOSITORY_URL] PATH: /SECURITY.md -VERSION: 02.33.00 +VERSION: 02.34.16 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index 97ab483e..1acd4081 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoWaaS.Build REPO: https://github.com/mokoconsulting-tech/mokowaas FILE: build-guide.md - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoWaaS system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> -# MokoWaaS Build Guide (VERSION: 02.33.00) +# MokoWaaS Build Guide (VERSION: 02.34.16) ## 1. Purpose @@ -44,7 +44,7 @@ The repository should maintain a clean, predictable, and modular structure suita ```text mokowaas/ - β”œβ”€β”€ src/ + β”œβ”€β”€ source/ β”‚ β”œβ”€β”€ mokowaas.php (main plugin file) β”‚ β”œβ”€β”€ mokowaas.xml (plugin manifest) β”‚ β”œβ”€β”€ services/ (service providers for DI) @@ -192,7 +192,7 @@ 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: | diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index b751daaa..c9772fb3 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoWaaS system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoWaaS Configuration Guide (VERSION: 02.33.00) +# MokoWaaS Configuration Guide (VERSION: 02.34.16) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 092389ef..b2589600 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoWaaS system plugin NOTE: First document in the guide set --> -# MokoWaaS Installation Guide (VERSION: 02.33.00) +# MokoWaaS Installation Guide (VERSION: 02.34.16) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 1b6b1182..fecfa1c3 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoWaaS system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoWaaS Operations Guide (VERSION: 02.33.00) +# MokoWaaS Operations Guide (VERSION: 02.34.16) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 3d00b8c3..8551b246 100644 --- a/docs/guides/rollback-and-recovery-guide.md +++ b/docs/guides/rollback-and-recovery-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/rollback-and-recovery-guide.md BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents NOTE: Completes the core guide set for WaaS plugin governance --> -# MokoWaaS Rollback and Recovery Guide (VERSION: 02.33.00) +# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.16) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 64947ff4..8ed781df 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/testing-guide.md BRIEF: Testing guide for MokoWaaS v02.01.08 NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration --> -# MokoWaaS Testing Guide (VERSION: 02.33.00) +# MokoWaaS Testing Guide (VERSION: 02.34.16) ## 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 @@ -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/MokoWaaS.php # Verify all override files have placeholders (no hardcoded "MokoWaaS" in values) -grep -r '"MokoWaaS' src/language/overrides/ src/administrator/language/overrides/ +grep -r '"MokoWaaS' 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 ``` diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index d073b8a9..a32adde1 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/troubleshooting-guide.md BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin NOTE: Designed for administrators and WaaS operations teams --> -# MokoWaaS Troubleshooting Guide (VERSION: 02.33.00) +# MokoWaaS Troubleshooting Guide (VERSION: 02.34.16) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 9dc524ca..cdf5d514 100644 --- a/docs/guides/upgrade-and-versioning-guide.md +++ b/docs/guides/upgrade-and-versioning-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/guides/upgrade-and-versioning-guide.md BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin NOTE: Defines release flow, version rules, and upgrade validation --> -# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.33.00) +# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.16) ## Introduction diff --git a/docs/index.md b/docs/index.md index 835baaaf..e57a964c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.34.16 PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoWaaS plugin NOTE: Automatically maintained index for all guide canvases --> -# MokoWaaS Documentation Index (VERSION: 02.33.00) +# MokoWaaS Documentation Index (VERSION: 02.34.16) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index b22774bc..bbd5b95c 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoWaaS REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: /docs/plugin-basic.md - VERSION: 02.33.00 + VERSION: 02.34.16 BRIEF: Baseline documentation for the MokoWaaS system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoWaaS Plugin Overview (VERSION: 02.33.00) +# MokoWaaS Plugin Overview (VERSION: 02.34.16) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 0231960b..ea96f1bd 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoWaaS.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoWaaS PATH: /docs/update-server.md -VERSION: 02.33.00 +VERSION: 02.34.16 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 | |-------------|--------|-------| @@ -136,7 +136,7 @@ The `repo_health.yml` workflow verifies on every commit: - ``, ``, ``, `` 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/` --- diff --git a/source/packages/com_mokowaas/admin/access.xml b/source/packages/com_mokowaas/admin/access.xml new file mode 100644 index 00000000..753c1ee1 --- /dev/null +++ b/source/packages/com_mokowaas/admin/access.xml @@ -0,0 +1,15 @@ + + +
+ + + + + + + + + + +
+
diff --git a/source/packages/com_mokowaas/admin/catalog.xml b/source/packages/com_mokowaas/admin/catalog.xml new file mode 100644 index 00000000..2122651b --- /dev/null +++ b/source/packages/com_mokowaas/admin/catalog.xml @@ -0,0 +1,92 @@ + + + + + MokoWaaS + pkg_mokowaas + package + Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API. + icon-shield-alt + Platform +
https://mokoconsulting.tech/support/products/mokowaas-platform
+ true + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/dev/updates.xml +
+ + MokoOnyx + mokoonyx + template + Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration. + icon-paint-brush + Templates +
https://mokoconsulting.tech/support/products/mokoonyx-template
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml +
+ + MokoJoomTOS + com_mokojoomtos + component + Terms of Service and privacy policy component with consent tracking. + icon-file-contract + Components +
https://mokoconsulting.tech/support/products/mokojoomtos
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/raw/branch/dev/updates.xml +
+ + MokoJoomHero + mod_mokojoomhero + module + Random hero image module from a configurable folder. + icon-image + Modules +
https://mokoconsulting.tech/support/products/mokojoomhero
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml +
+ + MokoWaaS Announce + mod_mokowaas_announce + module + Centralized announcement system via admin module. + icon-bullhorn + Modules +
https://mokoconsulting.tech/support/products/mokowaas-announce
+ https://git.mokoconsulting.tech/MokoConsulting/MokoWaaSAnnounce/raw/branch/dev/updates.xml +
+ + DPCalendar API + mokodpcalendarapi + plugin + Web Services plugin exposing DPCalendar events and calendars via REST API. + icon-calendar + Plugins +
https://mokoconsulting.tech/support/products/mokodpcalendarapi
+ https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml +
+ + Gallery Calendar + mokogallerycalendar + plugin + JoomGallery and DPCalendar integration β€” link galleries to events. + icon-images + Plugins +
https://mokoconsulting.tech/support/products/mokogallerycalendar
+ https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml +
+ + MokoJoomOpenGraph + pkg_mokoog + package + Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages. + icon-share-alt + Components +
https://mokoconsulting.tech/support/products/mokojoomopengraph
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml +
+
diff --git a/source/packages/com_mokowaas/admin/config.xml b/source/packages/com_mokowaas/admin/config.xml new file mode 100644 index 00000000..34e4e4e0 --- /dev/null +++ b/source/packages/com_mokowaas/admin/config.xml @@ -0,0 +1,47 @@ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ +
+
diff --git a/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini b/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini new file mode 100644 index 00000000..f8a00220 --- /dev/null +++ b/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini @@ -0,0 +1,41 @@ +; MokoWaaS Admin Dashboard - Language Strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel" +COM_MOKOWAAS_SITE="Site" +COM_MOKOWAAS_DATABASE="Database" +COM_MOKOWAAS_DEBUG_ON="Debug ON" +COM_MOKOWAAS_OFFLINE="Offline" +COM_MOKOWAAS_CLEAR_CACHE="Clear Cache" +COM_MOKOWAAS_CHECK_UPDATES="Check Updates" +COM_MOKOWAAS_ENABLED="Enabled" +COM_MOKOWAAS_DISABLED="Disabled" +COM_MOKOWAAS_PROTECTED="Protected" +COM_MOKOWAAS_CONFIGURE="Configure" +COM_MOKOWAAS_TOGGLE_SUCCESS="Plugin state updated." +COM_MOKOWAAS_TOGGLE_FAIL="Failed to update plugin state." +COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully." +COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions" +COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism β€” each package registers its own update server." +COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions" +COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker" +COM_MOKOWAAS_TICKETS_TITLE="Helpdesk" + +; ACL +COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard" +COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard." +COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions" +COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions." +COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess" +COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration." +COM_MOKOWAAS_ACL_TICKETS="View Tickets" +COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets." +COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets" +COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets." +COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets" +COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users." +COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins" +COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins." +COM_MOKOWAAS_ACL_CACHE="Clear Cache" +COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard." diff --git a/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini b/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini new file mode 100644 index 00000000..3c71dbd1 --- /dev/null +++ b/source/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini @@ -0,0 +1,19 @@ +; MokoWaaS Admin Dashboard - System Language Strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOWAAS="MokoWaaS" +COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management." +COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel" +COM_MOKOWAAS_MENU_DASHBOARD="Dashboard" +COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions" +COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins" +COM_MOKOWAAS_MENU_UPDATES="Joomla Updates" +COM_MOKOWAAS_MENU_CHECKIN="Global Check-in" +COM_MOKOWAAS_MENU_TICKETS="Helpdesk" +COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker" +COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard" +COM_MOKOWAAS_MENU_WAFLOG="WAF Log" +COM_MOKOWAAS_MENU_DATABASE="Database Tools" +COM_MOKOWAAS_MENU_CLEANUP="Cache Cleanup" +COM_MOKOWAAS_MENU_CACHE="Cache Management" diff --git a/src/packages/com_mokowaas/admin/services/provider.php b/source/packages/com_mokowaas/admin/services/provider.php similarity index 100% rename from src/packages/com_mokowaas/admin/services/provider.php rename to source/packages/com_mokowaas/admin/services/provider.php diff --git a/source/packages/com_mokowaas/admin/sql/install.mysql.sql b/source/packages/com_mokowaas/admin/sql/install.mysql.sql new file mode 100644 index 00000000..0bd447a0 --- /dev/null +++ b/source/packages/com_mokowaas/admin/sql/install.mysql.sql @@ -0,0 +1,135 @@ +-- +-- MokoWaaS Helpdesk Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `alias` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT, + `auto_assign_user` INT DEFAULT NULL, + `sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480, + `sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880, + `ordering` INT NOT NULL DEFAULT 0, + `published` TINYINT NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_alias` (`alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `subject` VARCHAR(512) NOT NULL, + `body` TEXT NOT NULL, + `status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open', + `priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal', + `category_id` INT UNSIGNED DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + `assigned_to` INT DEFAULT NULL, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `resolved` DATETIME DEFAULT NULL, + `closed` DATETIME DEFAULT NULL, + `sla_response_due` DATETIME DEFAULT NULL, + `sla_resolution_due` DATETIME DEFAULT NULL, + `sla_responded` TINYINT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_priority` (`priority`), + KEY `idx_assigned` (`assigned_to`), + KEY `idx_category` (`category_id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `user_id` INT NOT NULL DEFAULT 0, + `body` TEXT NOT NULL, + `is_internal` TINYINT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_ticket` (`ticket_id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `body` TEXT NOT NULL, + `category_id` INT UNSIGNED DEFAULT NULL, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created', + `conditions` TEXT NOT NULL DEFAULT '[]', + `actions` TEXT NOT NULL DEFAULT '[]', + `enabled` TINYINT NOT NULL DEFAULT 1, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Default automation rules +INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES +(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1), +(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2), +(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created β€” requires immediate attention."}]', 1, 3); + +-- Default categories +INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES +(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1), +(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2), +(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3), +(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4), +(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5); + +-- +-- Privacy Guard Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `category` VARCHAR(50) NOT NULL, + `action` ENUM('granted','revoked') NOT NULL, + `ip_address` VARCHAR(45) NOT NULL DEFAULT '', + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_category` (`category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `type` ENUM('export','delete','anonymize') NOT NULL, + `status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending', + `notes` TEXT, + `processed_by` INT DEFAULT NULL, + `created` DATETIME NOT NULL, + `processed` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `content_type` VARCHAR(100) NOT NULL, + `retention_days` INT UNSIGNED NOT NULL DEFAULT 365, + `action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize', + `enabled` TINYINT NOT NULL DEFAULT 1, + `description` VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Default retention policies +INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES +(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'), +(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'), +(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'), +(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), +(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); diff --git a/source/packages/com_mokowaas/admin/src/Controller/DisplayController.php b/source/packages/com_mokowaas/admin/src/Controller/DisplayController.php new file mode 100644 index 00000000..e8ecb437 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Controller/DisplayController.php @@ -0,0 +1,719 @@ + required permission. + */ + private const VIEW_ACL = [ + 'dashboard' => 'mokowaas.dashboard', + 'extensions' => 'mokowaas.extensions', + 'htaccess' => 'mokowaas.htaccess', + 'tickets' => 'mokowaas.tickets', + 'ticket' => 'mokowaas.tickets', + 'privacy' => 'core.admin', + 'waflog' => 'core.admin', + 'categories' => 'mokowaas.tickets', + 'canned' => 'mokowaas.tickets', + 'automation' => 'core.admin', + 'database' => 'core.admin', + 'cleanup' => 'mokowaas.cache', + ]; + + public function display($cachable = false, $urlparams = []) + { + $view = $this->input->get('view', $this->default_view); + $acl = self::VIEW_ACL[$view] ?? 'core.manage'; + + if (!$this->checkAcl($acl)) + { + Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + Factory::getApplication()->redirect(Route::_('index.php', false)); + + return; + } + + return parent::display($cachable, $urlparams); + } + + // ================================================================== + // Plugin toggle + // ================================================================== + + public function togglePlugin() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.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); + } + + // ================================================================== + // Cache + // ================================================================== + + public function clearCache() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.cache')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Dashboard')->clearCache()); + } + + public function clearTemp() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.cache')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Dashboard')->clearTemp()); + } + + // ================================================================== + // Extensions + // ================================================================== + + public function installExtension() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.extensions')) + { + $this->jsonForbidden(); + return; + } + + $downloadUrl = Factory::getApplication()->getInput()->getString('download_url', ''); + + if (empty($downloadUrl)) + { + $this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']); + return; + } + + $this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl)); + } + + // ================================================================== + // .htaccess + // ================================================================== + + public function saveHtaccess() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.htaccess')) + { + $this->jsonForbidden(); + return; + } + + $app = Factory::getApplication(); + $input = $app->getInput(); + $model = $this->getModel('Htaccess'); + + $options = []; + + foreach ($input->getArray() as $key => $value) + { + if (str_starts_with($key, 'opt_')) + { + $options[substr($key, 4)] = $value; + } + } + + if (!empty($options)) + { + $model->saveOptions($options); + } + + $this->jsonResponse($model->saveHtaccess($input->getRaw('content', ''))); + } + + public function generateHtaccess() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.htaccess')) + { + $this->jsonForbidden(); + return; + } + + $model = $this->getModel('Htaccess'); + $options = Factory::getApplication()->getInput()->getArray(); + + $model->saveOptions($options); + + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json'); + echo json_encode([ + 'htaccess' => $model->generateHtaccess($options), + 'nginx' => $model->generateNginx($options), + ]); + $app->close(); + } + + // ================================================================== + // Tickets + // ================================================================== + + public function createTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets.create')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + + $this->jsonResponse($this->getModel('Tickets')->createTicket([ + 'subject' => $input->getString('subject', ''), + 'body' => $input->getRaw('body', ''), + 'priority' => $input->getString('priority', 'normal'), + 'category_id' => $input->getInt('category_id', 0), + ])); + } + + public function addTicketReply() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + + $this->jsonResponse($this->getModel('Tickets')->addReply( + $input->getInt('ticket_id', 0), + $input->getRaw('body', ''), + (bool) $input->getInt('is_internal', 0) + )); + } + + public function updateTicketStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + + $this->jsonResponse($this->getModel('Tickets')->updateStatus( + $input->getInt('ticket_id', 0), + $input->getString('status', '') + )); + } + + // ================================================================== + // KB Search + // ================================================================== + + public function searchKb() + { + $query = Factory::getApplication()->getInput()->getString('q', ''); + + if (strlen($query) < 3) + { + $this->jsonResponse(['results' => []]); + } + + try + { + $db = Factory::getDbo(); + $escaped = $db->quote('%' . $db->escape($query, true) . '%'); + + $results = $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')]) + ->from($db->quoteName('#__finder_links', 'l')) + ->where($db->quoteName('l.published') . ' = 1') + ->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped + . ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')') + ->order($db->quoteName('l.title') . ' ASC') + ->setLimit(8) + )->loadObjectList() ?: []; + + foreach ($results as $r) + { + $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + } + + $this->jsonResponse(['results' => $results]); + } + catch (\Throwable $e) + { + $this->jsonResponse(['results' => []]); + } + } + + // ================================================================== + // Maintenance (#127, #128) + // ================================================================== + + public function optimizeDb() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->optimizeTables()); + } + + public function repairDb() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->repairTables()); + } + + public function purgeSessions() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->purgeSessions()); + } + + public function cleanDirectory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.cache')) { $this->jsonForbidden(); return; } + $dirKey = Factory::getApplication()->getInput()->getString('dir_key', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->cleanDirectory($dirKey)); + } + + // ================================================================== + // Helpdesk CRUD (#137, #138, #139) + // ================================================================== + + public function saveCategory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + $id = $input->getInt('id', 0); + $data = (object) [ + 'title' => $input->getString('title', ''), + 'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')), + 'sla_response_minutes' => $input->getInt('sla_response_minutes', 480), + 'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880), + 'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null, + 'published' => $input->getInt('published', 1), + ]; + if ($id) { + $data->id = $id; + $db->updateObject('#__mokowaas_ticket_categories', $data, 'id'); + } else { + $data->ordering = 0; + $db->insertObject('#__mokowaas_ticket_categories', $data, 'id'); + } + $this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]); + } + + public function deleteCategory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); + } + + public function saveCanned() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + $data = (object) [ + 'title' => $input->getString('title', ''), + 'body' => $input->getRaw('body', ''), + 'category_id' => $input->getInt('category_id', 0) ?: null, + 'ordering' => 0, + ]; + $id = $input->getInt('id', 0); + if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_canned', $data, 'id'); } + else { $db->insertObject('#__mokowaas_ticket_canned', $data, 'id'); } + $this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]); + } + + public function deleteCanned() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); + } + + public function saveAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + $data = (object) [ + 'title' => $input->getString('title', ''), + 'trigger_event' => $input->getString('trigger_event', 'ticket_created'), + 'conditions' => $input->getRaw('conditions', '[]'), + 'actions' => $input->getRaw('actions', '[]'), + 'enabled' => 1, + 'ordering' => 0, + ]; + $id = $input->getInt('id', 0); + if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_automation', $data, 'id'); } + else { $db->insertObject('#__mokowaas_ticket_automation', $data, 'id'); } + $this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]); + } + + public function deleteAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']); + } + + public function toggleAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->update('#__mokowaas_ticket_automation') + ->set('enabled = ' . $input->getInt('enabled', 0)) + ->where('id = ' . $input->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); + } + + // ================================================================== + // Settings Import/Export (#132) + // ================================================================== + + public function exportSettings() + { + Session::checkToken('get') or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $db = Factory::getDbo(); + $settings = []; + + // Export all MokoWaaS plugin params + $plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline']; + + foreach ($plugins as $element) + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true); + } + + // Export component params + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $settings['component'] = json_decode($db->loadResult() ?? '{}', true); + $settings['exported'] = gmdate('Y-m-d\TH:i:s\Z'); + $settings['site'] = Factory::getConfig()->get('sitename', ''); + + $this->jsonResponse(['success' => true, 'settings' => $settings]); + } + + public function importSettings() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $json = Factory::getApplication()->getInput()->getRaw('settings_json', ''); + $data = json_decode($json, true); + + if (empty($data) || empty($data['plugins'])) + { + $this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']); + return; + } + + $db = Factory::getDbo(); + $count = 0; + + foreach ($data['plugins'] ?? [] as $element => $params) + { + if (!is_array($params)) + { + continue; + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + $count++; + } + + if (!empty($data['component']) && is_array($data['component'])) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component']))) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + $count++; + } + + $this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]); + } + + // ================================================================== + // WAF Log + // ================================================================== + + public function purgeWafLog() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $days = Factory::getApplication()->getInput()->getInt('days', 30); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel(); + + $this->jsonResponse($model->purgeLogs($days)); + } + + public function banIpFromLog() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $ip = Factory::getApplication()->getInput()->getString('ip', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel(); + + $this->jsonResponse($model->banIp($ip)); + } + + // ================================================================== + // Privacy Guard + // ================================================================== + + public function processDataRequest() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + $action = $input->getString('action', 'deny'); + + if ($action === 'create') + { + $result = $model->createRequest( + $input->getInt('user_id', 0), + $input->getString('type', 'export') + ); + $this->jsonResponse($result); + return; + } + + if ($action === 'approve' && !$input->getInt('request_id', 0) && $input->getInt('user_id', 0)) + { + // Auto-process: create then immediately approve + $result = $model->createRequest( + $input->getInt('user_id', 0), + $input->getString('type', 'export') + ); + + if ($result['success'] && !empty($result['id'])) + { + $result = $model->processRequest((int) $result['id'], 'approve'); + } + + $this->jsonResponse($result); + return; + } + + $this->jsonResponse($model->processRequest( + $input->getInt('request_id', 0), + $action + )); + } + + public function exportUserData() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + + $this->jsonResponse($model->exportUserData( + Factory::getApplication()->getInput()->getInt('user_id', 0) + )); + } + + // ================================================================== + // Importers + // ================================================================== + + public function importAts() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Import')->importAts()); + } + + public function importAdminTools() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Import')->importAdminTools()); + } + + // ================================================================== + // Helpers + // ================================================================== + + /** + * Check a MokoWaaS ACL permission for the current user. + */ + private function checkAcl(string $action): bool + { + $user = Factory::getApplication()->getIdentity(); + + // Super admins always pass + if ($user->authorise('core.admin', 'com_mokowaas')) + { + return true; + } + + return $user->authorise($action, 'com_mokowaas'); + } + + /** + * Send a JSON response and close. + */ + private function jsonResponse(array $data): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json'); + echo json_encode($data); + $app->close(); + } + + /** + * Send a 403 JSON response and close. + */ + private function jsonForbidden(): void + { + $this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); +return; + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php b/source/packages/com_mokowaas/admin/src/Model/DashboardModel.php similarity index 56% rename from src/packages/com_mokowaas/admin/src/Model/DashboardModel.php rename to source/packages/com_mokowaas/admin/src/Model/DashboardModel.php index 8c9d3834..35a38556 100644 --- a/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php +++ b/source/packages/com_mokowaas/admin/src/Model/DashboardModel.php @@ -22,39 +22,60 @@ class DashboardModel extends BaseDatabaseModel */ private const PLUGIN_META = [ 'mokowaas' => [ - 'icon' => 'icon-shield-alt', - 'category' => 'core', - 'label' => 'Core β€” Branding & Identity', - 'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.', - 'protected' => true, + 'icon' => 'icon-shield-alt', + 'category' => 'core', + 'label' => 'Core', + 'description' => 'Heartbeat, health monitoring, site aliases, extension coordination, and download key preservation.', + 'protected' => true, + 'configure_only' => false, ], 'mokowaas_firewall' => [ - 'icon' => 'icon-lock', - 'category' => 'security', - 'label' => 'Firewall', - 'description' => 'Web Application Firewall β€” SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.', - 'protected' => false, + 'icon' => 'icon-lock', + 'category' => 'security', + 'label' => 'Firewall', + 'description' => 'Web Application Firewall β€” SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.', + 'protected' => false, + 'configure_only' => false, ], 'mokowaas_tenant' => [ - 'icon' => 'icon-users', - 'category' => 'security', - 'label' => 'Tenant Restrictions', - 'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.', - 'protected' => false, + 'icon' => 'icon-users', + 'category' => 'security', + 'label' => 'Tenant Restrictions', + 'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.', + 'protected' => false, + 'configure_only' => false, + ], + 'mokowaas_offline' => [ + 'icon' => 'icon-globe', + 'category' => 'security', + 'label' => 'Offline Bypass', + 'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.', + 'protected' => false, + 'configure_only' => true, ], 'mokowaas_devtools' => [ - 'icon' => 'icon-wrench', - 'category' => 'tools', - 'label' => 'Developer Tools', - 'description' => 'Dev mode, hit counter reset, content version cleanup.', - 'protected' => false, + 'icon' => 'icon-wrench', + 'category' => 'tools', + 'label' => 'Developer Tools', + 'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.', + 'protected' => false, + 'configure_only' => true, ], - 'mokowaas_monitor' => [ - 'icon' => 'icon-heartbeat', - 'category' => 'monitoring', - 'label' => 'Health Monitor', - 'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.', - 'protected' => false, + 'mokowaasdemo' => [ + 'icon' => 'icon-undo', + 'category' => 'content', + 'label' => 'Demo Reset Task', + 'description' => 'Scheduled demo site reset with content snapshots.', + 'protected' => false, + 'configure_only' => true, + ], + 'mokowaassync' => [ + 'icon' => 'icon-sync', + 'category' => 'content', + 'label' => 'Content Sync Task', + 'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.', + 'protected' => false, + 'configure_only' => true, ], ]; @@ -97,7 +118,8 @@ class DashboardModel extends BaseDatabaseModel '(' . $db->quoteName('type') . ' = ' . $db->quote('plugin') . ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system') . ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))' + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')' + . ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')' // Webservices plugins . ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin') . ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices') @@ -120,8 +142,10 @@ class DashboardModel extends BaseDatabaseModel $manifest = json_decode($row->manifest_cache ?? '{}'); $version = $manifest->version ?? ''; - // Build a lookup key: system plugins use element, others use folder_element - $metaKey = $row->element; + // Only system plugins and task plugins match PLUGIN_META by element + $metaKey = ($row->folder === 'system' || $row->folder === 'task') + ? $row->element + : $row->folder . '_' . $row->element; $meta = self::PLUGIN_META[$metaKey] ?? null; @@ -135,19 +159,20 @@ class DashboardModel extends BaseDatabaseModel $categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools']; $plugins[] = (object) [ - 'extension_id' => (int) $row->extension_id, - 'name' => $meta['label'] ?? $row->name, - 'element' => $row->element, - 'folder' => $row->folder, - 'type' => $row->type, - 'enabled' => (int) $row->enabled, - 'protected' => (int) $row->protected || ($meta['protected'] ?? false), - 'version' => $version, - 'icon' => $meta['icon'] ?? 'icon-puzzle-piece', - 'category' => $categoryKey, + 'extension_id' => (int) $row->extension_id, + 'name' => $meta['label'] ?? $row->name, + 'element' => $row->element, + 'folder' => $row->folder, + 'type' => $row->type, + 'enabled' => (int) $row->enabled, + 'protected' => (bool) ($meta['protected'] ?? false), + 'configure_only' => (bool) ($meta['configure_only'] ?? false), + 'version' => $version, + 'icon' => $meta['icon'] ?? 'icon-puzzle-piece', + 'category' => $categoryKey, 'categoryLabel' => $categoryInfo['label'], 'categoryBadge' => $categoryInfo['badge'], - 'description' => $meta['description'] ?? '', + 'description' => $meta['description'] ?? '', ]; } @@ -187,6 +212,54 @@ class DashboardModel extends BaseDatabaseModel ]; } + /** + * Get installed MokoWaaS 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_mokowaas') . ')' + // Admin modules + . ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module') + . ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokowaas%') . ')' + . ')') + ->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. * @@ -242,13 +315,18 @@ class DashboardModel extends BaseDatabaseModel { try { - $app = Factory::getApplication(); - $app->get('cache_handler', 'file'); - - // Clear site and admin caches + // Use Joomla's native cache API β€” same as com_cache $cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class); - Factory::getCache('', '')->gc(); - Factory::getCache('', '', 'administrator')->gc(); + $cache->createCacheController('', ['defaultgroup' => ''])->cache->clean(''); + + // Also clean admin cache + $conf = Factory::getApplication()->get('cache_handler', 'file'); + $options = [ + 'defaultgroup' => '', + 'cachebase' => JPATH_ADMINISTRATOR . '/cache', + 'storage' => $conf, + ]; + $cache->createCacheController('', $options)->cache->clean(''); // Clear opcache if available if (\function_exists('opcache_reset')) @@ -256,7 +334,7 @@ class DashboardModel extends BaseDatabaseModel \opcache_reset(); } - return ['success' => true, 'message' => 'Cache cleared successfully.']; + return ['success' => true, 'message' => 'All cache cleared successfully.']; } catch (\Throwable $e) { @@ -264,6 +342,62 @@ class DashboardModel extends BaseDatabaseModel } } + /** + * 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. */ @@ -422,4 +556,84 @@ class DashboardModel extends BaseDatabaseModel return []; } } + + /** + * WAF blocks per day for the last 14 days. + */ + public function getWafBlocksByDay(int $days = 14): array + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + "SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total" + . " FROM " . $db->quoteName('#__mokowaas_waf_log') + . " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)" + . " GROUP BY day ORDER BY day" + ); + $rows = $db->loadObjectList() ?: []; + + // Fill in missing days with zero + $result = []; + $date = new \DateTime("-{$days} days"); + $now = new \DateTime('now'); + $map = []; + foreach ($rows as $r) + { + $map[$r->day] = (int) $r->total; + } + while ($date <= $now) + { + $key = $date->format('Y-m-d'); + $result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0]; + $date->modify('+1 day'); + } + + return $result; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Admin logins per day for the last 14 days. + */ + public function getLoginsByDay(int $days = 14): array + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + "SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total" + . " FROM " . $db->quoteName('#__action_logs') + . " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'" + . " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)" + . " GROUP BY day ORDER BY day" + ); + $rows = $db->loadObjectList() ?: []; + + $result = []; + $date = new \DateTime("-{$days} days"); + $now = new \DateTime('now'); + $map = []; + foreach ($rows as $r) + { + $map[$r->day] = (int) $r->total; + } + while ($date <= $now) + { + $key = $date->format('Y-m-d'); + $result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0]; + $date->modify('+1 day'); + } + + return $result; + } + catch (\Throwable $e) + { + return []; + } + } } diff --git a/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php b/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php new file mode 100644 index 00000000..cc42dd3e --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php @@ -0,0 +1,321 @@ +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'] ?? ''; + + $status = 'not_installed'; + + if ($localVersion !== null) + { + $status = 'installed'; + + if ($remoteVersion !== '' && version_compare($remoteVersion, $localVersion, '>')) + { + $status = 'update_available'; + } + } + + $extensionId = $this->getExtensionId($entry['element']); + + $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, + ]; + } + + return $packages; + } + + /** + * Install an extension from a remote ZIP URL. + * + * @param string $url The download URL + * + * @return array Result with success, message, and extension info + */ + public function installFromUrl(string $url): array + { + $tmpPath = Factory::getConfig()->get('tmp_path', JPATH_ROOT . '/tmp'); + $tmpFile = $tmpPath . '/mokowaas_install_' . md5($url) . '.zip'; + + try + { + $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_mokowaas/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 []; + } + + // Find the highest version entry + $bestVersion = '0.0.0'; + $downloadUrl = ''; + + foreach ($xml->update as $update) + { + $ver = (string) ($update->version ?? ''); + + if ($ver === '' || version_compare($ver, $bestVersion, '<=')) + { + continue; + } + + $bestVersion = $ver; + + // Get download URL from + if (isset($update->downloads->downloadurl)) + { + $downloadUrl = (string) $update->downloads->downloadurl; + } + } + + if ($bestVersion === '0.0.0') + { + return []; + } + + return [ + 'version' => $bestVersion, + 'download_url' => $downloadUrl, + ]; + } + + /** + * 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; + } + + /** + * 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(); + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/HtaccessModel.php b/source/packages/com_mokowaas/admin/src/Model/HtaccessModel.php new file mode 100644 index 00000000..5997b19c --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/HtaccessModel.php @@ -0,0 +1,522 @@ + 1, + 'block_sensitive_files' => 1, + 'block_php_in_uploads' => 1, + 'disable_server_signature' => 1, + 'prevent_clickjacking' => 1, + 'prevent_mime_sniffing' => 1, + 'xss_protection' => 1, + 'disable_trace_track' => 1, + 'referrer_policy' => 'strict-origin-when-cross-origin', + 'hsts_enabled' => 0, + 'hsts_max_age' => 31536000, + 'hsts_subdomains' => 0, + 'csp_enabled' => 0, + 'csp_value' => '', + 'permissions_policy' => 0, + 'permissions_value' => '', + // Performance + 'enable_gzip' => 1, + 'enable_expires' => 1, + 'expires_html' => 3600, + 'expires_css_js' => 2592000, + 'expires_images' => 31536000, + 'etag_control' => 0, + // SEO + 'www_redirect' => 'off', + 'redirect_index_php' => 1, + 'force_trailing_slash' => 0, + // Custom + 'custom_rules' => '', + ]; + + /** + * Get saved options or defaults. + */ + public function getOptions(): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $htaccess = $params->get('htaccess', null); + + if ($htaccess) + { + return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true)); + } + + return self::DEFAULTS; + } + + /** + * Save options to component params. + */ + public function saveOptions(array $options): array + { + try + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $clean = []; + + foreach (self::DEFAULTS as $key => $default) + { + $clean[$key] = $options[$key] ?? $default; + } + + $params->set('htaccess', $clean); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + + return ['success' => true, 'message' => 'Options saved.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()]; + } + } + + /** + * Read the current .htaccess file. + */ + public function readCurrentHtaccess(): string + { + $path = JPATH_ROOT . '/.htaccess'; + + return file_exists($path) ? file_get_contents($path) : ''; + } + + /** + * Write .htaccess to disk with backup. + */ + public function saveHtaccess(string $content): array + { + $path = JPATH_ROOT . '/.htaccess'; + $backup = JPATH_ROOT . '/.htaccess.mokowaas.bak'; + + try + { + // Backup existing + if (file_exists($path)) + { + copy($path, $backup); + } + + $result = file_put_contents($path, $content); + + if ($result === false) + { + // Restore backup + if (file_exists($backup)) + { + copy($backup, $path); + } + + return ['success' => false, 'message' => '.htaccess is not writable.']; + } + + return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak']; + } + catch (\Throwable $e) + { + if (file_exists($backup)) + { + @copy($backup, $path); + } + + return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()]; + } + } + + /** + * Generate .htaccess content from options. + */ + public function generateHtaccess(array $opts): string + { + $lines = []; + $lines[] = '##'; + $lines[] = '## MokoWaaS Generated .htaccess'; + $lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC'; + $lines[] = '## DO NOT EDIT β€” regenerate from MokoWaaS > .htaccess Maker'; + $lines[] = '##'; + $lines[] = ''; + + // --- Security --- + if (!empty($opts['disable_directory_listing'])) + { + $lines[] = '## Disable directory listing'; + $lines[] = 'Options -Indexes'; + $lines[] = ''; + } + + if (!empty($opts['disable_server_signature'])) + { + $lines[] = '## Hide server signature'; + $lines[] = 'ServerSignature Off'; + $lines[] = ''; + $lines[] = ' Header unset X-Powered-By'; + $lines[] = ' Header unset Server'; + $lines[] = ''; + $lines[] = ''; + } + + if (!empty($opts['block_sensitive_files'])) + { + $lines[] = '## Block access to sensitive files'; + $lines[] = ''; + $lines[] = ' '; + $lines[] = ' Require all denied'; + $lines[] = ' '; + $lines[] = ''; + $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[] = ''; + $lines[] = ' '; + $lines[] = ' '; + $lines[] = ' Require all denied'; + $lines[] = ' '; + $lines[] = ' '; + $lines[] = ''; + } + + $lines[] = ''; + } + + if (!empty($opts['disable_trace_track'])) + { + $lines[] = '## Disable TRACE and TRACK methods'; + $lines[] = ''; + $lines[] = ' RewriteEngine On'; + $lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)'; + $lines[] = ' RewriteRule .* - [F]'; + $lines[] = ''; + $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[] = ''; + $lines = array_merge($lines, $headers); + $lines[] = ''; + $lines[] = ''; + } + + // --- Performance --- + if (!empty($opts['enable_gzip'])) + { + $lines[] = '## GZip compression'; + $lines[] = ''; + $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[] = ''; + $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[] = ''; + $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[] = ''; + $lines[] = ''; + } + + if (!empty($opts['etag_control'])) + { + $lines[] = '## Disable ETags (for load-balanced environments)'; + $lines[] = ''; + $lines[] = ' Header unset ETag'; + $lines[] = ''; + $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[] = ''; + $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[] = ''; + $lines[] = ''; + } + + // --- Custom rules --- + $custom = trim($opts['custom_rules'] ?? ''); + + if (!empty($custom)) + { + $lines[] = '## Custom rules'; + $lines[] = $custom; + $lines[] = ''; + } + + return implode("\n", $lines); + } + + /** + * Generate equivalent NginX configuration snippet. + */ + public function generateNginx(array $opts): string + { + $lines = []; + $lines[] = '## MokoWaaS Generated NginX Configuration'; + $lines[] = '## Add these directives inside your server { } block'; + $lines[] = ''; + + if (!empty($opts['disable_directory_listing'])) + { + $lines[] = '# Disable directory listing'; + $lines[] = 'autoindex off;'; + $lines[] = ''; + } + + if (!empty($opts['disable_server_signature'])) + { + $lines[] = '# Hide server version'; + $lines[] = 'server_tokens off;'; + $lines[] = ''; + } + + if (!empty($opts['block_sensitive_files'])) + { + $lines[] = '# Block sensitive files'; + $lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {'; + $lines[] = ' deny all;'; + $lines[] = '}'; + $lines[] = ''; + } + + if (!empty($opts['block_php_in_uploads'])) + { + $lines[] = '# Block PHP in upload directories'; + $lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {'; + $lines[] = ' deny all;'; + $lines[] = '}'; + $lines[] = ''; + } + + // Headers + $hdrs = []; + + if (!empty($opts['prevent_clickjacking'])) + { + $hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;'; + } + + if (!empty($opts['prevent_mime_sniffing'])) + { + $hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;'; + } + + if (!empty($opts['xss_protection'])) + { + $hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;'; + } + + $referrer = $opts['referrer_policy'] ?? ''; + + if (!empty($referrer) && $referrer !== 'off') + { + $hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;'; + } + + if (!empty($opts['hsts_enabled'])) + { + $maxAge = (int) ($opts['hsts_max_age'] ?? 31536000); + $hsts = 'max-age=' . $maxAge; + + if (!empty($opts['hsts_subdomains'])) + { + $hsts .= '; includeSubDomains'; + } + + $hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;'; + } + + if (!empty($hdrs)) + { + $lines[] = '# Security headers'; + $lines = array_merge($lines, $hdrs); + $lines[] = ''; + } + + if (!empty($opts['enable_gzip'])) + { + $lines[] = '# GZip compression'; + $lines[] = 'gzip on;'; + $lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;'; + $lines[] = 'gzip_min_length 256;'; + $lines[] = ''; + } + + if (!empty($opts['enable_expires'])) + { + $cssJs = (int) ($opts['expires_css_js'] ?? 2592000); + $images = (int) ($opts['expires_images'] ?? 31536000); + + $lines[] = '# Browser caching'; + $lines[] = 'location ~* \.(css|js)$ {'; + $lines[] = ' expires ' . round($cssJs / 86400) . 'd;'; + $lines[] = '}'; + $lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {'; + $lines[] = ' expires ' . round($images / 86400) . 'd;'; + $lines[] = '}'; + $lines[] = ''; + } + + return implode("\n", $lines); + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/ImportModel.php b/source/packages/com_mokowaas/admin/src/Model/ImportModel.php new file mode 100644 index 00000000..352c6adf --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/ImportModel.php @@ -0,0 +1,688 @@ +wasImported('admintools')) + { + return null; + } + + $db = $this->getDatabase(); + + try + { + $result = (object) [ + 'component' => false, + 'waf_config' => false, + 'storage' => false, + 'ip_blocks' => 0, + ]; + + // Check component + $db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'"); + $result->component = (int) $db->loadResult() > 0; + + // Check WAF config table + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%')); + + if ($db->loadResult()) + { + $result->waf_config = true; + $db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig'); + $result->waf_settings = (int) $db->loadResult(); + } + + // Check storage table + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%')); + + if ($db->loadResult()) + { + $result->storage = true; + } + + // Check IP blocklist + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%')); + + if ($db->loadResult()) + { + $db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock'); + $result->ip_blocks = (int) $db->loadResult(); + } + + // Only available if at least one data source exists + if (!$result->component && !$result->waf_config && !$result->storage) + { + return null; + } + + return $result; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import Admin Tools settings into MokoWaaS. + */ + public function importAdminTools(): array + { + $db = $this->getDatabase(); + $results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false]; + + try + { + // ============================================================ + // 1. Import WAF Config β†’ Firewall plugin params + // ============================================================ + $wafSettings = $this->readWafConfig($db); + $firewallParams = $this->mapWafToFirewall($wafSettings); + + if (!empty($firewallParams)) + { + $this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams); + $results['firewall'] = \count($firewallParams); + } + + // ============================================================ + // 2. Import htaccess settings β†’ component htaccess options + // ============================================================ + $htaccessSettings = $this->readHtaccessConfig($db); + $htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings); + + if (!empty($htaccessOptions)) + { + $this->mergeComponentHtaccessOptions($htaccessOptions); + $results['htaccess'] = \count($htaccessOptions); + } + + // ============================================================ + // 3. Import IP blocklist β†’ Firewall IP deny list + // ============================================================ + $ipBlocks = $this->readIpBlocklist($db); + + if (!empty($ipBlocks)) + { + $this->mergeIpBlocklist($ipBlocks); + $results['ip_blocks'] = \count($ipBlocks); + } + + // ============================================================ + // 4. Disable Admin Tools + // ============================================================ + $this->disableAdminTools($db); + $results['disabled'] = true; + + $this->markImported('admintools'); + + return [ + 'success' => true, + 'message' => \sprintf( + 'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.', + $results['firewall'], $results['htaccess'], $results['ip_blocks'] + ), + 'counts' => $results, + ]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } + + /** + * Read WAF config from #__admintools_wafconfig. + */ + private function readWafConfig($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT * FROM #__admintools_wafconfig'); + $rows = $db->loadObjectList() ?: []; + + $config = []; + + foreach ($rows as $row) + { + $key = $row->key ?? $row->option ?? ''; + + if (!empty($key)) + { + $config[$key] = $row->value ?? ''; + } + } + + return $config; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Read htaccess/server config from #__admintools_storage. + */ + private function readHtaccessConfig($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT * FROM #__admintools_storage'); + $rows = $db->loadObjectList() ?: []; + + $config = []; + + foreach ($rows as $row) + { + $key = $row->key ?? ''; + + if (!empty($key)) + { + $config[$key] = $row->value ?? ''; + } + } + + return $config; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Read IP blocklist from #__admintools_ipblock. + */ + private function readIpBlocklist($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT ip FROM #__admintools_ipblock'); + + return $db->loadColumn() ?: []; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Map Admin Tools WAF config to MokoWaaS firewall plugin params. + */ + private function mapWafToFirewall(array $waf): array + { + $params = []; + + // WAF shields + if (isset($waf['sqlishield'])) + { + $params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0; + } + + if (isset($waf['antispam'])) + { + $params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0; + } + + if (isset($waf['muashield'])) + { + $params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0; + } + + if (isset($waf['rfishield'])) + { + $params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0; + } + + if (isset($waf['dfishield'])) + { + $params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0; + } + + if (isset($waf['uploadshield'])) + { + // Map to our block_direct_php + $params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0; + } + + // Admin secret URL + if (!empty($waf['adminpw'])) + { + $params['admin_secret'] = $waf['adminpw']; + } + + // Block frontend super user login + if (isset($waf['nofesalogin'])) + { + $params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0; + } + + // Session timeout + if (!empty($waf['sessionshield']) && !empty($waf['session_timeout'])) + { + $params['admin_session_timeout'] = (int) $waf['session_timeout']; + } + + // Template switch blocking + if (isset($waf['tmpl'])) + { + $params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0; + } + + // Blocked sensitive files + if (isset($waf['hogfiles'])) + { + $params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0; + } + + return $params; + } + + /** + * Map Admin Tools config to MokoWaaS htaccess maker options. + */ + private function mapToHtaccess(array $storage, array $waf): array + { + $opts = []; + + // Server signature + if (isset($waf['serversignature']) || isset($storage['serversignature'])) + { + $opts['disable_server_signature'] = 1; + } + + // Clickjacking + if (isset($waf['clickjacking']) || isset($storage['xframeoptions'])) + { + $opts['prevent_clickjacking'] = 1; + } + + // HSTS + if (!empty($storage['hstsheader']) || !empty($waf['hstsheader'])) + { + $opts['hsts_enabled'] = 1; + + if (!empty($storage['hstsmaxage'])) + { + $opts['hsts_max_age'] = (int) $storage['hstsmaxage']; + } + } + + // GZip + if (isset($storage['gzipcompression'])) + { + $opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0; + } + + // Expiration + if (isset($storage['exptime'])) + { + $opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0; + } + + // ETag + if (isset($storage['etagtype'])) + { + $opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0; + } + + // Redirect www / non-www + if (!empty($storage['wwwredir'])) + { + $map = ['www' => 'www', 'nowww' => 'non-www']; + $opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off'; + } + + // Directory listing + if (isset($storage['nodirlisting'])) + { + $opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0; + } + + // Block PHP in uploads + if (isset($storage['phpuploadexec'])) + { + $opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0; + } + + // Sensitive files + if (isset($storage['hogfiles'])) + { + $opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0; + } + + return $opts; + } + + /** + * Merge params into a plugin's existing params. + */ + private function mergePluginParams(string $element, string $folder, array $newParams): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($folder)); + $db->setQuery($query); + $current = new Registry($db->loadResult() ?? '{}'); + + foreach ($newParams as $key => $value) + { + $current->set($key, $value); + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($current->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($folder)) + )->execute(); + } + + /** + * Merge htaccess options into the component params. + */ + private function mergeComponentHtaccessOptions(array $options): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true); + + foreach ($options as $key => $value) + { + $htaccess[$key] = $value; + } + + $params->set('htaccess', $htaccess); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + } + + /** + * Merge imported IPs into the firewall IP blocklist. + */ + private function mergeIpBlocklist(array $ips): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + $existingIps = array_column($blocklist, 'ip'); + + foreach ($ips as $ip) + { + $ip = trim($ip); + + if (empty($ip) || \in_array($ip, $existingIps, true)) + { + continue; + } + + $blocklist[] = [ + 'ip' => $ip, + 'enabled' => '1', + 'label' => 'Imported from Admin Tools', + ]; + } + + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + } + + /** + * Disable Admin Tools component and plugins. + */ + private function disableAdminTools($db): void + { + // Disable component + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools')) + )->execute(); + + // Disable all Admin Tools plugins + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + )->execute(); + + Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas'); + } + + // ================================================================== + // Akeeba Ticket System Import + // ================================================================== + + /** + * Check if ATS tables exist. + * Returns null if already imported or no data found. + */ + public function checkAtsAvailable(): ?object + { + if ($this->wasImported('ats')) + { + return null; + } + + $db = $this->getDatabase(); + + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%')); + + if (!$db->loadResult()) + { + return null; + } + + $db->setQuery('SELECT COUNT(*) FROM #__ats_tickets'); + $tickets = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_posts'); + $posts = (int) $db->loadResult(); + + return (object) ['tickets' => $tickets, 'posts' => $posts]; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import from Akeeba Ticket System and disable it. + */ + public function importAts(): array + { + // Delegate to TicketsModel for the actual import + $ticketsModel = new TicketsModel(); + $result = $ticketsModel->importFromAts(); + + if (!$result['success']) + { + return $result; + } + + // Disable ATS after successful import + try + { + $db = $this->getDatabase(); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' = ' . $db->quote('com_ats')) + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + )->execute(); + + $result['message'] .= ' Akeeba Ticket System has been disabled.'; + Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas'); + } + catch (\Throwable $e) + { + $result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage(); + } + + $this->markImported('ats'); + + return $result; + } + + // ================================================================== + // Import markers (stored in component params) + // ================================================================== + + private function wasImported(string $key): bool + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $params = new Registry($db->loadResult() ?? '{}'); + + return (bool) $params->get('imported_' . $key, false); + } + catch (\Throwable $e) + { + return false; + } + } + + private function markImported(string $key): void + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $params = new Registry($db->loadResult() ?? '{}'); + $params->set('imported_' . $key, 1); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + } + catch (\Throwable $e) + { + Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php b/source/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php new file mode 100644 index 00000000..9d8aa946 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php @@ -0,0 +1,251 @@ +getDatabase(); + $prefix = $db->getPrefix(); + + $db->setQuery('SHOW TABLE STATUS'); + $tables = $db->loadObjectList() ?: []; + + $results = []; + $totalSize = 0; + $totalOverhead = 0; + + foreach ($tables as $t) + { + $sizeMb = round(($t->Data_length + $t->Index_length) / 1048576, 2); + $overheadKb = round(($t->Data_free ?? 0) / 1024, 1); + $totalSize += $sizeMb; + $totalOverhead += $overheadKb; + + $results[] = (object) [ + 'name' => $t->Name, + 'rows' => (int) $t->Rows, + 'engine' => $t->Engine, + 'size_mb' => $sizeMb, + 'overhead_kb' => $overheadKb, + 'is_moko' => str_contains($t->Name, 'mokowaas'), + ]; + } + + usort($results, fn($a, $b) => $b->size_mb <=> $a->size_mb); + + return ['tables' => $results, 'total_size_mb' => round($totalSize, 2), 'total_overhead_kb' => round($totalOverhead, 1), 'count' => \count($results)]; + } + + /** + * Optimize all tables or specific ones. + */ + public function optimizeTables(array $tableNames = []): array + { + $db = $this->getDatabase(); + $count = 0; + + try + { + if (empty($tableNames)) + { + $db->setQuery('SHOW TABLE STATUS WHERE Data_free > 0'); + $tables = $db->loadObjectList() ?: []; + $tableNames = array_column($tables, 'Name'); + } + + foreach ($tableNames as $name) + { + $db->setQuery('OPTIMIZE TABLE ' . $db->quoteName($name)); + $db->execute(); + $count++; + } + + return ['success' => true, 'message' => "Optimized {$count} tables."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Optimize failed: ' . $e->getMessage()]; + } + } + + /** + * Repair all tables. + */ + public function repairTables(): array + { + $db = $this->getDatabase(); + + try + { + $db->setQuery('SHOW TABLE STATUS'); + $tables = $db->loadObjectList() ?: []; + $count = 0; + + foreach ($tables as $t) + { + if ($t->Engine === 'InnoDB' || $t->Engine === 'MyISAM') + { + $db->setQuery('REPAIR TABLE ' . $db->quoteName($t->Name)); + $db->execute(); + $count++; + } + } + + return ['success' => true, 'message' => "Repaired {$count} tables."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Repair failed: ' . $e->getMessage()]; + } + } + + /** + * Purge expired sessions. + */ + public function purgeSessions(): array + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__session')) + ->where($db->quoteName('time') . ' < ' . (time() - 86400)) + )->execute(); + + return ['success' => true, 'message' => 'Expired sessions purged. ' . $db->getAffectedRows() . ' removed.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + // ================================================================== + // Temp/Cache Cleanup (#128) + // ================================================================== + + /** + * Get directory sizes for cleanup. + */ + public function getCleanupInfo(): array + { + $dirs = [ + ['path' => JPATH_ROOT . '/cache', 'label' => 'Site Cache'], + ['path' => JPATH_ADMINISTRATOR . '/cache', 'label' => 'Admin Cache'], + ['path' => JPATH_ROOT . '/tmp', 'label' => 'Temp Directory'], + ['path' => JPATH_ADMINISTRATOR . '/logs', 'label' => 'Log Files'], + ]; + + $results = []; + + foreach ($dirs as $dir) + { + $size = 0; + $files = 0; + + if (is_dir($dir['path'])) + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir['path'], \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) + { + if ($file->isFile()) + { + $size += $file->getSize(); + $files++; + } + } + } + + $results[] = (object) [ + 'label' => $dir['label'], + 'path' => $dir['path'], + 'size_mb' => round($size / 1048576, 2), + 'files' => $files, + 'writable' => is_writable($dir['path']), + ]; + } + + return $results; + } + + /** + * Clean a specific directory. + */ + public function cleanDirectory(string $dirKey): array + { + $allowed = [ + 'site_cache' => JPATH_ROOT . '/cache', + 'admin_cache' => JPATH_ADMINISTRATOR . '/cache', + 'tmp' => JPATH_ROOT . '/tmp', + 'logs' => JPATH_ADMINISTRATOR . '/logs', + ]; + + if (!isset($allowed[$dirKey])) + { + return ['success' => false, 'message' => 'Invalid directory.']; + } + + $dir = $allowed[$dirKey]; + + if (!is_dir($dir)) + { + return ['success' => false, 'message' => 'Directory not found.']; + } + + $count = 0; + + try + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) + { + // Keep index.html and .htaccess files + $name = $item->getFilename(); + + if ($name === 'index.html' || $name === '.htaccess') + { + continue; + } + + if ($item->isDir()) + { + @rmdir($item->getPathname()); + } + else + { + @unlink($item->getPathname()); + $count++; + } + } + + // Also clear opcache + if (\function_exists('opcache_reset')) + { + \opcache_reset(); + } + + return ['success' => true, 'message' => "Cleaned {$count} files from {$dirKey}."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Cleanup failed: ' . $e->getMessage()]; + } + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/PrivacyModel.php b/source/packages/com_mokowaas/admin/src/Model/PrivacyModel.php new file mode 100644 index 00000000..3f91e084 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/PrivacyModel.php @@ -0,0 +1,612 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r') . '.*', + $db->quoteName('u.name', 'user_name'), + $db->quoteName('u.email', 'user_email'), + $db->quoteName('u.username'), + $db->quoteName('p.name', 'processed_by_name'), + ]) + ->from($db->quoteName('#__mokowaas_data_requests', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by'); + + if ($filterStatus) + { + $query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus)); + } + + $query->order($db->quoteName('r.created') . ' DESC')->setLimit(50); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Create a data request (from admin or user self-service). + */ + public function createRequest(int $userId, string $type, string $notes = ''): array + { + $validTypes = ['export', 'delete', 'anonymize']; + + if (!\in_array($type, $validTypes, true)) + { + return ['success' => false, 'message' => 'Invalid request type.']; + } + + try + { + $db = $this->getDatabase(); + $row = (object) [ + 'user_id' => $userId, + 'type' => $type, + 'status' => 'pending', + 'notes' => $notes, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokowaas_data_requests', $row, 'id'); + + return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Process a data request (approve and execute). + */ + public function processRequest(int $requestId, string $action): array + { + $db = $this->getDatabase(); + + try + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_data_requests')) + ->where($db->quoteName('id') . ' = ' . $requestId) + ); + $request = $db->loadObject(); + + if (!$request) + { + return ['success' => false, 'message' => 'Request not found.']; + } + + if ($action === 'deny') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('denied')) + ->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id) + ->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + return ['success' => true, 'message' => 'Request denied.']; + } + + // Mark as processing + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('processing')) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + // Execute the request + $result = null; + + switch ($request->type) + { + case 'export': + $result = $this->exportUserData((int) $request->user_id); + break; + + case 'delete': + $result = $this->deleteUserData((int) $request->user_id); + break; + + case 'anonymize': + $result = $this->anonymizeUserData((int) $request->user_id); + break; + } + + // Mark completed + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id) + ->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + return $result ?? ['success' => true, 'message' => 'Request processed.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()]; + } + } + + /** + * Export all data for a user as a structured array. + */ + public function exportUserData(int $userId): array + { + $db = $this->getDatabase(); + $data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')]; + + try + { + // User profile + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params']) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + ); + $data['profile'] = $db->loadObject(); + + // Content (articles) + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'title', 'alias', 'created', 'modified', 'hits']) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $data['articles'] = $db->loadObjectList() ?: []; + + // Action logs + $db->setQuery( + $db->getQuery(true) + ->select(['message', 'log_date', 'ip_address']) + ->from($db->quoteName('#__action_logs')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order('log_date DESC') + ->setLimit(100) + ); + $data['action_logs'] = $db->loadObjectList() ?: []; + + // Support tickets + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'subject', 'body', 'status', 'priority', 'created']) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $data['tickets'] = $db->loadObjectList() ?: []; + + // Ticket replies + $db->setQuery( + $db->getQuery(true) + ->select(['r.id', 'r.ticket_id', 'r.body', 'r.created']) + ->from($db->quoteName('#__mokowaas_ticket_replies', 'r')) + ->where($db->quoteName('r.user_id') . ' = ' . $userId) + ); + $data['ticket_replies'] = $db->loadObjectList() ?: []; + + // Consent log + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order('created ASC') + ); + $data['consent_history'] = $db->loadObjectList() ?: []; + + // Community Builder profile (if table exists) + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%')); + + if ($db->loadResult()) + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__comprofiler')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ); + $data['community_builder'] = $db->loadObject(); + } + } + catch (\Throwable $e) {} + + return ['success' => true, 'message' => 'Data exported.', 'data' => $data]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()]; + } + } + + /** + * Anonymize a user's data (GDPR right to be forgotten β€” soft). + */ + public function anonymizeUserData(int $userId): array + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + $anon = 'Anonymous User #' . $userId; + + try + { + // Anonymize user record + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__users')) + ->set([ + $db->quoteName('name') . ' = ' . $db->quote($anon), + $db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId), + $db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'), + $db->quoteName('password') . ' = ' . $db->quote(''), + $db->quoteName('block') . ' = 1', + $db->quoteName('params') . ' = ' . $db->quote('{}'), + ]) + ->where($db->quoteName('id') . ' = ' . $userId) + )->execute(); + + // Anonymize article authorship + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__content')) + ->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon)) + ->where($db->quoteName('created_by') . ' = ' . $userId) + )->execute(); + + // Delete action logs + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__action_logs')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Anonymize ticket replies + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_ticket_replies')) + ->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Community Builder + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%')); + + if ($db->loadResult()) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__comprofiler')) + ->set([ + $db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'), + $db->quoteName('lastname') . ' = ' . $db->quote('User'), + $db->quoteName('middlename') . ' = ' . $db->quote(''), + ]) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + } + catch (\Throwable $e) {} + + // Clear Joomla user profile fields (#7) + try + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__user_profiles')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + catch (\Throwable $e) {} + + // Clear contact details if linked + try + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__contact_details')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + catch (\Throwable $e) {} + + // Log the anonymization + $this->logConsent($userId, 'account_anonymized', 'granted'); + + return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()]; + } + } + + /** + * Delete a user's data completely (hard delete). + */ + public function deleteUserData(int $userId): array + { + $result = $this->anonymizeUserData($userId); + + if (!$result['success']) + { + return $result; + } + + $db = $this->getDatabase(); + + try + { + // Delete tickets and replies + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $ticketIds = $db->loadColumn() ?: []; + + if (!empty($ticketIds)) + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_ticket_replies')) + ->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')') + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + )->execute(); + } + + // Delete consent log + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Delete user record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + )->execute(); + + return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()]; + } + } + + // ================================================================== + // Consent Management + // ================================================================== + + /** + * Get consent status for a user. + */ + public function getUserConsent(int $userId): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order($db->quoteName('created') . ' DESC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Record a consent action. + */ + public function logConsent(int $userId, string $category, string $action): void + { + $db = $this->getDatabase(); + $row = (object) [ + 'user_id' => $userId, + 'category' => $category, + 'action' => $action === 'revoked' ? 'revoked' : 'granted', + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '', + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokowaas_consent_log', $row, 'id'); + } + + // ================================================================== + // Retention Policy Enforcement + // ================================================================== + + /** + * Get all retention policies. + */ + public function getRetentionPolicies(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_retention_policies')) + ->order($db->quoteName('id') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Run retention policy enforcement (called by scheduled task). + */ + public function enforceRetentionPolicies(): array + { + $db = $this->getDatabase(); + $results = ['policies_run' => 0, 'items_affected' => 0]; + $policies = $this->getRetentionPolicies(); + + foreach ($policies as $policy) + { + if (!(int) $policy->enabled) + { + continue; + } + + $cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql(); + $count = 0; + + try + { + switch ($policy->content_type) + { + case 'action_logs': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__action_logs')) + ->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'waf_logs': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'sessions': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__session')) + ->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'closed_tickets': + if ($policy->action === 'anonymize') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]')) + ->where($db->quoteName('status') . ' = ' . $db->quote('closed')) + ->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]')) + )->execute(); + $count = $db->getAffectedRows(); + } + break; + + case 'inactive_users': + if ($policy->action === 'anonymize') + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00')) + ->where($db->quoteName('block') . ' = 0') + ->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%')) + ); + $userIds = $db->loadColumn() ?: []; + + foreach ($userIds as $uid) + { + $this->anonymizeUserData((int) $uid); + $count++; + } + } + break; + } + + if ($count > 0) + { + $results['policies_run']++; + $results['items_affected'] += $count; + Log::add(\sprintf('Retention: %s β€” %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas'); + } + } + catch (\Throwable $e) + { + Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + return $results; + } + + /** + * Get privacy dashboard summary counts. + */ + public function getDashboardSummary(): object + { + $db = $this->getDatabase(); + + $summary = (object) [ + 'pending_requests' => 0, + 'total_requests' => 0, + 'consent_entries' => 0, + 'policies_active' => 0, + ]; + + try + { + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending')); + $summary->pending_requests = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests'); + $summary->total_requests = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log'); + $summary->consent_entries = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1'); + $summary->policies_active = (int) $db->loadResult(); + } + catch (\Throwable $e) {} + + return $summary; + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/TicketsModel.php b/source/packages/com_mokowaas/admin/src/Model/TicketsModel.php new file mode 100644 index 00000000..34bfc928 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/TicketsModel.php @@ -0,0 +1,945 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t.id'), + $db->quoteName('t.subject'), + $db->quoteName('t.status'), + $db->quoteName('t.priority'), + $db->quoteName('t.created'), + $db->quoteName('t.modified'), + $db->quoteName('t.sla_response_due'), + $db->quoteName('t.sla_resolution_due'), + $db->quoteName('t.sla_responded'), + $db->quoteName('c.title', 'category_title'), + $db->quoteName('u.name', 'created_by_name'), + $db->quoteName('a.name', 'assigned_to_name'), + ]) + ->from($db->quoteName('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to'); + + if (!empty($filters['status'])) + { + $query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status'])); + } + + if (!empty($filters['priority'])) + { + $query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority'])); + } + + if (!empty($filters['assigned_to'])) + { + $query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']); + } + + if (!empty($filters['category_id'])) + { + $query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']); + } + + $query->order($db->quoteName('t.created') . ' DESC'); + $query->setLimit(50); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get a single ticket with all replies. + */ + public function getTicket(int $id): ?object + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t') . '.*', + $db->quoteName('c.title', 'category_title'), + $db->quoteName('u.name', 'created_by_name'), + $db->quoteName('u.email', 'created_by_email'), + $db->quoteName('a.name', 'assigned_to_name'), + ]) + ->from($db->quoteName('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to') + ->where($db->quoteName('t.id') . ' = ' . $id); + $db->setQuery($query); + $ticket = $db->loadObject(); + + if (!$ticket) + { + return null; + } + + // Load replies + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r') . '.*', + $db->quoteName('u.name', 'user_name'), + ]) + ->from($db->quoteName('#__mokowaas_ticket_replies', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->where($db->quoteName('r.ticket_id') . ' = ' . $id) + ->order($db->quoteName('r.created') . ' ASC'); + $db->setQuery($query); + $ticket->replies = $db->loadObjectList() ?: []; + + // Reply count + $ticket->reply_count = \count($ticket->replies); + + return $ticket; + } + + /** + * Create a new ticket. + */ + public function createTicket(array $data): array + { + try + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $now = Factory::getDate()->toSql(); + + $ticket = (object) [ + 'subject' => $data['subject'] ?? '', + 'body' => $data['body'] ?? '', + 'status' => 'open', + 'priority' => $data['priority'] ?? 'normal', + 'category_id' => (int) ($data['category_id'] ?? 0) ?: null, + 'created_by' => $user->id, + 'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null, + 'created' => $now, + 'modified' => $now, + ]; + + // Auto-assign from category + if (!$ticket->assigned_to && $ticket->category_id) + { + $query = $db->getQuery(true) + ->select($db->quoteName('auto_assign_user')) + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); + $db->setQuery($query); + $autoAssign = (int) $db->loadResult(); + + if ($autoAssign) + { + $ticket->assigned_to = $autoAssign; + } + } + + // SLA deadlines from category + if ($ticket->category_id) + { + $query = $db->getQuery(true) + ->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')]) + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); + $db->setQuery($query); + $sla = $db->loadObject(); + + if ($sla) + { + $ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql(); + $ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql(); + } + } + + $db->insertObject('#__mokowaas_tickets', $ticket, 'id'); + + // Run automation + notifications + $this->runAutomation('ticket_created', (int) $ticket->id); + NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id)); + + return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Add a reply to a ticket. + */ + public function addReply(int $ticketId, string $body, bool $isInternal = false): array + { + try + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $now = Factory::getDate()->toSql(); + + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => $user->id, + 'body' => $body, + 'is_internal' => $isInternal ? 1 : 0, + 'created' => $now, + ]; + + $db->insertObject('#__mokowaas_ticket_replies', $reply, 'id'); + + // Mark SLA as responded only for staff replies (not customer self-replies) + $ticket = $this->getTicket($ticketId); + $isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by; + + $updateQuery = $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId); + + if ($isStaffReply) + { + $updateQuery->set($db->quoteName('sla_responded') . ' = 1') + ->where($db->quoteName('sla_responded') . ' = 0'); + } + + $db->setQuery($updateQuery)->execute(); + + // Run automation + notifications (skip internal notes) + $this->runAutomation('ticket_replied', $ticketId); + + if (!$isInternal) + { + NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]); + } + + return ['success' => true, 'message' => 'Reply added.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Update ticket status. + */ + public function updateStatus(int $ticketId, string $status): array + { + $valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed']; + + if (!\in_array($status, $valid, true)) + { + return ['success' => false, 'message' => 'Invalid status.']; + } + + try + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + // Capture old status for notification + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('status')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('id') . ' = ' . $ticketId) + ); + $oldStatus = $db->loadResult() ?? ''; + + $sets = [ + $db->quoteName('status') . ' = ' . $db->quote($status), + $db->quoteName('modified') . ' = ' . $db->quote($now), + ]; + + if ($status === 'resolved') + { + $sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now); + } + + if ($status === 'closed') + { + $sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now); + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($sets) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + + // Run automation + notifications + $this->runAutomation('status_changed', $ticketId); + NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]); + + return ['success' => true, 'message' => 'Status updated to ' . $status . '.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Get all ticket categories. + */ + public function getCategories(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get canned responses, optionally filtered by category. + */ + public function getCannedResponses(int $categoryId = 0): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_canned')) + ->order($db->quoteName('ordering') . ' ASC'); + + if ($categoryId) + { + $query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId + . ' OR ' . $db->quoteName('category_id') . ' IS NULL)'); + } + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get ticket counts by status for dashboard. + */ + public function getStatusCounts(): object + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')]) + ->from($db->quoteName('#__mokowaas_tickets')) + ->group($db->quoteName('status')) + ); + $rows = $db->loadObjectList('status') ?: []; + + return (object) [ + 'open' => (int) ($rows['open']->cnt ?? 0), + 'in_progress' => (int) ($rows['in_progress']->cnt ?? 0), + 'waiting' => (int) ($rows['waiting']->cnt ?? 0), + 'resolved' => (int) ($rows['resolved']->cnt ?? 0), + 'closed' => (int) ($rows['closed']->cnt ?? 0), + 'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)), + ]; + } + + /** + * Get overdue tickets (SLA breached). + */ + public function getOverdueTickets(): array + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'), + $db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')]) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)' + . ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')') + ->order($db->quoteName('sla_resolution_due') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + // ================================================================== + // Automation Engine + // ================================================================== + + /** + * Run automation rules for a specific trigger event against a ticket. + * + * @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled + * @param int $ticketId The ticket to evaluate + */ + public function runAutomation(string $event, int $ticketId): void + { + try + { + $db = $this->getDatabase(); + + // Load enabled rules for this event + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return; + } + + // Load the ticket + $ticket = $this->getTicket($ticketId); + + if (!$ticket) + { + return; + } + + // Calculate age in hours + $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if ($this->evaluateConditions($conditions, $ticket)) + { + $this->executeActions($actions, $ticketId, $ticket); + } + } + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + + /** + * Run all scheduled automation rules against all open tickets. + */ + public function runScheduledAutomation(): array + { + $db = $this->getDatabase(); + $results = ['evaluated' => 0, 'acted' => 0]; + + // Load scheduled rules + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled')) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return $results; + } + + // Load all non-closed tickets + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('status') . ' != ' . $db->quote('closed')); + $db->setQuery($query); + $tickets = $db->loadObjectList() ?: []; + + foreach ($tickets as $ticket) + { + $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; + $ticket->replies = []; + $results['evaluated']++; + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if ($this->evaluateConditions($conditions, $ticket)) + { + $this->executeActions($actions, (int) $ticket->id, $ticket); + $results['acted']++; + } + } + } + + return $results; + } + + /** + * Evaluate a set of conditions against a ticket (all must match). + */ + private function evaluateConditions(array $conditions, object $ticket): bool + { + foreach ($conditions as $cond) + { + $field = $cond['field'] ?? ''; + $op = $cond['op'] ?? 'eq'; + $value = $cond['value'] ?? ''; + + $ticketValue = $ticket->{$field} ?? null; + + if ($ticketValue === null) + { + return false; + } + + switch ($op) + { + case 'eq': + if ((string) $ticketValue !== (string) $value) return false; + break; + case 'neq': + if ((string) $ticketValue === (string) $value) return false; + break; + case 'gt': + if ((float) $ticketValue <= (float) $value) return false; + break; + case 'lt': + if ((float) $ticketValue >= (float) $value) return false; + break; + case 'in': + $list = array_map('trim', explode(',', $value)); + if (!\in_array((string) $ticketValue, $list, true)) return false; + break; + case 'not_in': + $list = array_map('trim', explode(',', $value)); + if (\in_array((string) $ticketValue, $list, true)) return false; + break; + default: + return false; + } + } + + return true; + } + + /** + * Execute a set of actions on a ticket. + */ + private function executeActions(array $actions, int $ticketId, object $ticket): void + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + foreach ($actions as $action) + { + $type = $action['type'] ?? ''; + $value = $action['value'] ?? ''; + + switch ($type) + { + case 'set_status': + $this->updateStatus($ticketId, $value); + break; + + case 'set_priority': + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('priority') . ' = ' . $db->quote($value)) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + break; + + case 'assign': + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('assigned_to') . ' = ' . (int) $value) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + break; + + case 'add_note': + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => 0, + 'body' => $value, + 'is_internal' => 1, + 'created' => $now, + ]; + $db->insertObject('#__mokowaas_ticket_replies', $reply, 'id'); + break; + + case 'send_email': + // value = email address or comma-separated list + $emails = array_filter(array_map('trim', explode(',', $value))); + + foreach ($emails as $email) + { + try + { + $mailer = Factory::getMailer(); + $mailer->addRecipient($email); + $mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert'); + $mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? '')); + $mailer->isHtml(false); + $mailer->Send(); + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + break; + + case 'create_ticket': + // value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"} + $ticketData = json_decode($value, true) ?: []; + $behavior = $ticketData['behavior'] ?? 'append'; + $userId = (int) ($ticket->created_by ?? 0); + $catId = (int) ($ticketData['category_id'] ?? 0); + + if ($behavior === 'append' && $userId > 0) + { + // Check for existing open ticket from this user in this category + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1') + ->order($db->quoteName('created') . ' DESC') + ->setLimit(1) + ); + $existingId = (int) $db->loadResult(); + + if ($existingId) + { + $this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true); + break; + } + } + elseif ($behavior === 'skip_if_open' && $userId > 0) + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ); + + if ((int) $db->loadResult() > 0) + { + break; + } + } + + // Create new ticket + $this->createTicket([ + 'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'), + 'body' => $ticketData['body'] ?? '', + 'priority' => $ticketData['priority'] ?? 'normal', + 'category_id' => $catId, + ]); + break; + } + } + } + + /** + * Run automation for a system event (not tied to a specific ticket). + * Creates a virtual ticket context from event data. + */ + public function runSystemEventAutomation(string $event, array $eventData = []): void + { + try + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return; + } + + // Build a virtual ticket-like object from event data + $context = (object) array_merge([ + 'id' => 0, + 'subject' => $eventData['subject'] ?? $event, + 'body' => $eventData['body'] ?? '', + 'status' => 'open', + 'priority' => $eventData['priority'] ?? 'normal', + 'created_by' => $eventData['user_id'] ?? 0, + 'created' => gmdate('Y-m-d H:i:s'), + 'age_hours' => 0, + ], $eventData); + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if (empty($conditions) || $this->evaluateConditions($conditions, $context)) + { + $this->executeActions($actions, 0, $context); + } + } + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + + /** + * Get all automation rules. + */ + public function getAutomationRules(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->order($db->quoteName('ordering') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + // ================================================================== + // Akeeba Ticket System Importer + // ================================================================== + + /** + * Check if ATS tables exist and return counts. + */ + public function checkAtsAvailable(): ?object + { + $db = $this->getDatabase(); + + try + { + $db->setQuery('SELECT COUNT(*) FROM #__ats_tickets'); + $tickets = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_posts'); + $posts = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies'); + $canned = (int) $db->loadResult(); + + return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned]; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import tickets, replies, and canned responses from Akeeba Ticket System. + */ + public function importFromAts(): array + { + $db = $this->getDatabase(); + $results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []]; + + try + { + // Status mapping: ATS β†’ MokoWaaS + $statusMap = [ + 'O' => 'open', // Open + 'P' => 'in_progress', // Pending (staff action needed) + 'C' => 'closed', // Closed + ]; + // Numeric statuses 1-99 are custom β€” map to open + for ($i = 1; $i <= 99; $i++) + { + $statusMap[(string) $i] = 'open'; + } + + // Priority mapping: ATS uses 1-5, we use enum + $priorityMap = [ + 1 => 'low', + 2 => 'low', + 3 => 'normal', + 4 => 'high', + 5 => 'urgent', + ]; + + // Category mapping: ATS uses Joomla categories, map catid to our category + // Default all to General Support (1) β€” admin can reassign later + $defaultCategory = 1; + + // Import canned replies first + $db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering'); + $atsCanned = $db->loadObjectList() ?: []; + + foreach ($atsCanned as $c) + { + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokowaas_ticket_canned') + ->where($db->quoteName('title') . ' = ' . $db->quote($c->title)) + )->loadResult(); + + if ((int) $exists > 0) + { + continue; + } + + $row = (object) [ + 'title' => $c->title, + 'body' => strip_tags($c->reply ?? ''), + 'category_id' => null, + 'ordering' => (int) ($c->ordering ?? 0), + ]; + $db->insertObject('#__mokowaas_ticket_canned', $row, 'id'); + $results['canned']++; + } + + // Import tickets + $db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id'); + $atsTickets = $db->loadObjectList() ?: []; + + $ticketIdMap = []; // ATS id β†’ MokoWaaS id + + foreach ($atsTickets as $t) + { + // Skip if already imported (check by subject + created_by + created) + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokowaas_tickets') + ->where($db->quoteName('subject') . ' = ' . $db->quote($t->title)) + ->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by) + )->loadResult(); + + if ((int) $exists > 0) + { + continue; + } + + $status = $statusMap[$t->status] ?? 'open'; + $priority = $priorityMap[(int) $t->priority] ?? 'normal'; + + $row = (object) [ + 'subject' => $t->title, + 'body' => '', + 'status' => $status, + 'priority' => $priority, + 'category_id' => $defaultCategory, + 'created_by' => (int) $t->created_by, + 'assigned_to' => (int) $t->assigned_to ?: null, + 'created' => $t->created ?: Factory::getDate()->toSql(), + 'modified' => $t->modified, + 'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null, + 'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null, + 'sla_responded' => 1, + ]; + + $db->insertObject('#__mokowaas_tickets', $row, 'id'); + $ticketIdMap[(int) $t->id] = (int) $row->id; + $results['tickets']++; + } + + // Import posts (replies) + $db->setQuery('SELECT * FROM #__ats_posts ORDER BY id'); + $atsPosts = $db->loadObjectList() ?: []; + + foreach ($atsPosts as $p) + { + $newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null; + + if (!$newTicketId) + { + continue; + } + + // First post of a ticket is usually the ticket body β€” update the ticket + if (empty($results['first_post_' . $p->ticket_id])) + { + $results['first_post_' . $p->ticket_id] = true; + $body = strip_tags($p->content_html ?? ''); + $db->setQuery( + $db->getQuery(true) + ->update('#__mokowaas_tickets') + ->set($db->quoteName('body') . ' = ' . $db->quote($body)) + ->where($db->quoteName('id') . ' = ' . $newTicketId) + )->execute(); + + continue; + } + + $row = (object) [ + 'ticket_id' => $newTicketId, + 'user_id' => (int) $p->created_by, + 'body' => strip_tags($p->content_html ?? ''), + 'is_internal' => 0, + 'created' => $p->created ?: Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokowaas_ticket_replies', $row, 'id'); + $results['replies']++; + } + + // Clean up temp tracking keys + foreach (array_keys($results) as $k) + { + if (str_starts_with($k, 'first_post_')) + { + unset($results[$k]); + } + } + + return [ + 'success' => true, + 'message' => sprintf( + 'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.', + $results['tickets'], $results['replies'], $results['canned'] + ), + 'counts' => $results, + ]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } +} diff --git a/source/packages/com_mokowaas/admin/src/Model/WaflogModel.php b/source/packages/com_mokowaas/admin/src/Model/WaflogModel.php new file mode 100644 index 00000000..591ba310 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Model/WaflogModel.php @@ -0,0 +1,215 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_waf_log')); + + if (!empty($filters['rule'])) + { + $query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule'])); + } + + if (!empty($filters['ip'])) + { + $query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%')); + } + + if (!empty($filters['search'])) + { + $search = $db->quote('%' . $db->escape($filters['search'], true) . '%'); + $query->where('(' . $db->quoteName('uri') . ' LIKE ' . $search + . ' OR ' . $db->quoteName('detail') . ' LIKE ' . $search + . ' OR ' . $db->quoteName('user_agent') . ' LIKE ' . $search . ')'); + } + + if (!empty($filters['date_from'])) + { + $query->where($db->quoteName('created') . ' >= ' . $db->quote($filters['date_from'] . ' 00:00:00')); + } + + if (!empty($filters['date_to'])) + { + $query->where($db->quoteName('created') . ' <= ' . $db->quote($filters['date_to'] . ' 23:59:59')); + } + + $query->order($db->quoteName('created') . ' DESC'); + $query->setLimit($limit, $offset); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get total count for pagination. + */ + public function getTotal(array $filters = []): int + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_waf_log')); + + if (!empty($filters['rule'])) + { + $query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule'])); + } + + if (!empty($filters['ip'])) + { + $query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%')); + } + + $db->setQuery($query); + + return (int) $db->loadResult(); + } + + /** + * Get block counts grouped by rule for the summary bar. + */ + public function getRuleCounts(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('rule'), 'COUNT(*) AS ' . $db->quoteName('cnt')]) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->group($db->quoteName('rule')) + ->order($db->quoteName('cnt') . ' DESC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get top blocked IPs. + */ + public function getTopIps(int $limit = 10): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('ip'), 'COUNT(*) AS ' . $db->quoteName('cnt'), + 'MAX(' . $db->quoteName('created') . ') AS ' . $db->quoteName('last_seen')]) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->group($db->quoteName('ip')) + ->order($db->quoteName('cnt') . ' DESC') + ->setLimit($limit) + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get distinct rule names for the filter dropdown. + */ + public function getRuleNames(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('rule')) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->order($db->quoteName('rule') . ' ASC') + ); + + return $db->loadColumn() ?: []; + } + + /** + * Delete logs older than N days. + */ + public function purgeLogs(int $days): array + { + try + { + $db = $this->getDatabase(); + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)) + )->execute(); + + $count = $db->getAffectedRows(); + + return ['success' => true, 'message' => "Purged {$count} log entries older than {$days} days."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Purge failed: ' . $e->getMessage()]; + } + } + + /** + * Add an IP to the firewall blocklist. + */ + public function banIp(string $ip, string $reason = 'Banned from WAF log'): array + { + try + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + + $params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}'); + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + // Check if already blocked + foreach ($blocklist as $entry) + { + if (($entry['ip'] ?? '') === $ip) + { + return ['success' => false, 'message' => $ip . ' is already blocked.']; + } + } + + $blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => $reason]; + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + return ['success' => true, 'message' => $ip . ' has been added to the IP blocklist.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Ban failed: ' . $e->getMessage()]; + } + } +} diff --git a/source/packages/com_mokowaas/admin/src/Service/NotificationService.php b/source/packages/com_mokowaas/admin/src/Service/NotificationService.php new file mode 100644 index 00000000..5f4ed16b --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/Service/NotificationService.php @@ -0,0 +1,416 @@ +isHtml(false); + $mailer->setSubject($subject); + $mailer->setBody($body); + + foreach ($recipients as $email) + { + $email = trim($email); + + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) + { + continue; + } + + try + { + $mailer->clearAddresses(); + $mailer->addRecipient($email); + $mailer->Send(); + } + catch (\Throwable $e) + { + Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + } + catch (\Throwable $e) + { + Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Determine recipients based on event type and ticket data. + */ + private static function getRecipients(string $event, object $ticket): array + { + $emails = []; + + // Get notification config from component params + $config = self::getNotificationConfig(); + + // Always notify configured admin emails + $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); + $emails = array_merge($emails, $adminEmails); + + // Always notify configured admin user IDs + $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); + + foreach ($adminUserIds as $uid) + { + $email = self::getUserEmail($uid); + + if ($email) + { + $emails[] = $email; + } + } + + switch ($event) + { + case 'ticket_created': + // Notify assigned user if any + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'ticket_replied': + // Notify ticket creator (customer gets notified of staff reply) + if (!empty($ticket->created_by)) + { + $email = self::getUserEmail((int) $ticket->created_by); + + if ($email) + { + $emails[] = $email; + } + } + + // Notify assigned user + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'status_changed': + // Notify ticket creator + if (!empty($ticket->created_by)) + { + $email = self::getUserEmail((int) $ticket->created_by); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'ticket_assigned': + // Notify newly assigned user + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + } + + return array_unique($emails); + } + + /** + * Build email subject line. + */ + private static function buildSubject(string $event, object $ticket): string + { + $siteName = Factory::getConfig()->get('sitename', 'Support'); + $prefix = '[' . $siteName . ' #' . $ticket->id . '] '; + + switch ($event) + { + case 'ticket_created': + return $prefix . 'New Ticket: ' . ($ticket->subject ?? ''); + + case 'ticket_replied': + return $prefix . 'Reply: ' . ($ticket->subject ?? ''); + + case 'status_changed': + return $prefix . 'Status Changed: ' . ($ticket->subject ?? ''); + + case 'ticket_assigned': + return $prefix . 'Assigned: ' . ($ticket->subject ?? ''); + + default: + return $prefix . ($ticket->subject ?? ''); + } + } + + /** + * Build email body. + */ + private static function buildBody(string $event, object $ticket, array $extra): string + { + $siteName = Factory::getConfig()->get('sitename', 'Support'); + $siteUrl = rtrim(Uri::root(), '/'); + $ticketUrl = $siteUrl . '/index.php?option=com_mokowaas&view=ticket&id=' . $ticket->id; + + $lines = []; + $lines[] = $siteName . ' Support'; + $lines[] = str_repeat('-', 40); + $lines[] = ''; + + switch ($event) + { + case 'ticket_created': + $lines[] = 'A new support ticket has been created.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); + $lines[] = 'Category: ' . ($ticket->category_title ?? 'General'); + $lines[] = ''; + + if (!empty($ticket->body)) + { + $lines[] = 'Description:'; + $lines[] = strip_tags($ticket->body); + $lines[] = ''; + } + break; + + case 'ticket_replied': + $lines[] = 'A new reply has been added to your ticket.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); + $lines[] = ''; + + if (!empty($extra['reply_body'])) + { + $lines[] = 'Reply:'; + $lines[] = strip_tags($extra['reply_body']); + $lines[] = ''; + } + break; + + case 'status_changed': + $lines[] = 'Your ticket status has been updated.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); + + if (!empty($extra['old_status'])) + { + $lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status'])); + } + + $lines[] = ''; + break; + + case 'ticket_assigned': + $lines[] = 'A ticket has been assigned to you.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); + $lines[] = ''; + break; + } + + $lines[] = 'View ticket: ' . $ticketUrl; + $lines[] = ''; + $lines[] = '-- '; + $lines[] = $siteName . ' | Powered by MokoWaaS'; + + return implode("\n", $lines); + } + + /** + * Get email address for a Joomla user ID. + */ + private static function getUserEmail(int $userId): ?string + { + if ($userId <= 0) + { + return null; + } + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('email')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + ); + + return $db->loadResult() ?: null; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Get notification configuration from component params. + */ + private static function getNotificationConfig(): array + { + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + + $params = json_decode($db->loadResult() ?? '{}', true); + + return $params['notifications'] ?? []; + } + catch (\Throwable $e) + { + return []; + } + } + + // ================================================================== + // Security Event Notifications (#131) + // ================================================================== + + /** + * Send a security alert to admin emails. + */ + public static function securityAlert(string $event, string $subject, string $body): void + { + try + { + $config = self::getNotificationConfig(); + $enabled = $config['security_alerts'] ?? '1'; + + if (!$enabled) + { + return; + } + + $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); + $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); + + $recipients = $adminEmails; + + foreach ($adminUserIds as $uid) + { + $email = self::getUserEmail($uid); + + if ($email) + { + $recipients[] = $email; + } + } + + $recipients = array_unique($recipients); + + if (empty($recipients)) + { + return; + } + + $siteName = Factory::getConfig()->get('sitename', 'Site'); + $fullSubject = '[' . $siteName . ' Security] ' . $subject; + + $lines = [ + $siteName . ' Security Alert', + str_repeat('-', 40), + '', + 'Event: ' . $event, + 'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC', + '', + $body, + '', + '-- ', + $siteName . ' | MokoWaaS Security', + ]; + + $mailer = Factory::getMailer(); + $mailer->isHtml(false); + $mailer->setSubject($fullSubject); + $mailer->setBody(implode("\n", $lines)); + + foreach ($recipients as $email) + { + try + { + $mailer->clearAddresses(); + $mailer->addRecipient(trim($email)); + $mailer->Send(); + } + catch (\Throwable $e) + { + Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + } + catch (\Throwable $e) + { + Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php new file mode 100644 index 00000000..01928e4a --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php @@ -0,0 +1,27 @@ +rules = $model->getAutomationRules(); + + ToolbarHelper::title('Automation Rules', 'cogs'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php new file mode 100644 index 00000000..2a391df2 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php @@ -0,0 +1,33 @@ +get('Joomla\Database\DatabaseInterface'); + + $db->setQuery('SELECT * FROM #__mokowaas_ticket_canned ORDER BY ordering ASC'); + $this->responses = $db->loadObjectList() ?: []; + + $db->setQuery('SELECT id, title FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering'); + $this->categories = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Canned Responses', 'comment'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php new file mode 100644 index 00000000..bebffae8 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php @@ -0,0 +1,41 @@ +get('Joomla\Database\DatabaseInterface'); + + $db->setQuery('SELECT * FROM #__mokowaas_ticket_categories ORDER BY ordering ASC'); + $this->categories = $db->loadObjectList() ?: []; + + // Get admin users for auto-assign dropdown + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('name')]) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('block') . ' = 0') + ->order($db->quoteName('name') . ' ASC') + ->setLimit(100) + ); + $this->users = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Ticket Categories', 'folder'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php new file mode 100644 index 00000000..14a9b44b --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php @@ -0,0 +1,27 @@ +dirs = $model->getCleanupInfo(); + + ToolbarHelper::title('Cache & Temp Cleanup', 'trash'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php similarity index 71% rename from src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php rename to source/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php index 323febf5..c191c8ca 100644 --- a/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php @@ -23,6 +23,9 @@ class HtmlView extends BaseHtmlView protected $pendingUpdates = []; protected $checkedOutItems = []; protected $wafBlocks = []; + protected $wafChartData = []; + protected $loginChartData = []; + protected $mokoExtensions = []; public function display($tpl = null) { @@ -34,6 +37,22 @@ class HtmlView extends BaseHtmlView $this->pendingUpdates = $model->getPendingUpdates(); $this->checkedOutItems = $model->getCheckedOutItems(); $this->wafBlocks = $model->getRecentWafBlocks(5); + $this->wafChartData = $model->getWafBlocksByDay(14); + $this->loginChartData = $model->getLoginsByDay(14); + $this->mokoExtensions = $model->getMokoExtensions(); + + // Check for importable Akeeba data + try + { + $importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel(); + $this->adminToolsAvailable = $importModel->checkAdminToolsAvailable(); + $this->atsAvailable = $importModel->checkAtsAvailable(); + } + catch (\Throwable $e) + { + $this->adminToolsAvailable = null; + $this->atsAvailable = null; + } $this->addToolbar(); diff --git a/source/packages/com_mokowaas/admin/src/View/Database/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Database/HtmlView.php new file mode 100644 index 00000000..6c91723d --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Database/HtmlView.php @@ -0,0 +1,27 @@ +tableData = $model->getTableStatus(); + + ToolbarHelper::title('Database Tools', 'database'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Extensions/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Extensions/HtmlView.php similarity index 100% rename from src/packages/com_mokowaas/admin/src/View/Extensions/HtmlView.php rename to source/packages/com_mokowaas/admin/src/View/Extensions/HtmlView.php diff --git a/source/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php new file mode 100644 index 00000000..1a7dc9de --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php @@ -0,0 +1,47 @@ +getModel(); + + $this->options = $model->getOptions(); + $this->preview = $model->generateHtaccess($this->options); + $this->nginxPreview = $model->generateNginx($this->options); + $this->currentHtaccess = $model->readCurrentHtaccess(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..b4d7e52d --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php @@ -0,0 +1,39 @@ +getInput()->getString('filter_status', ''); + $this->requests = $model->getDataRequests($filterStatus); + $this->policies = $model->getRetentionPolicies(); + $this->summary = $model->getDashboardSummary(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('Privacy Guard', 'lock'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php new file mode 100644 index 00000000..b4c00476 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php @@ -0,0 +1,53 @@ +getModel('Tickets'); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $this->ticket = $model->getTicket($id); + $this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0)); + + if (!$this->ticket) + { + Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); + Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets'); + + return; + } + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' β€” ' . $this->ticket->subject : 'Ticket'; + ToolbarHelper::title($title, 'headphones'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php new file mode 100644 index 00000000..98cacb97 --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php @@ -0,0 +1,56 @@ +getModel(); + $app = Factory::getApplication(); + + $filters = [ + 'status' => $app->getInput()->getString('filter_status', ''), + 'priority' => $app->getInput()->getString('filter_priority', ''), + 'category_id' => $app->getInput()->getInt('filter_category', 0), + ]; + + $this->tickets = $model->getTickets($filters); + $this->categories = $model->getCategories(); + $this->statusCounts = $model->getStatusCounts(); + $this->overdue = $model->getOverdueTickets(); + $this->atsAvailable = $model->checkAtsAvailable(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/source/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php b/source/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php new file mode 100644 index 00000000..e1f73a9c --- /dev/null +++ b/source/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php @@ -0,0 +1,55 @@ +getInput(); + + $this->filters = [ + 'rule' => $input->getString('filter_rule', ''), + 'ip' => $input->getString('filter_ip', ''), + 'search' => $input->getString('filter_search', ''), + 'date_from' => $input->getString('filter_date_from', ''), + 'date_to' => $input->getString('filter_date_to', ''), + ]; + + $page = max(1, $input->getInt('page', 1)); + $limit = 50; + $offset = ($page - 1) * $limit; + + $this->logs = $model->getLogs($this->filters, $limit, $offset); + $this->total = $model->getTotal($this->filters); + $this->ruleCounts = $model->getRuleCounts(); + $this->topIps = $model->getTopIps(10); + $this->ruleNames = $model->getRuleNames(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('WAF Log Viewer', 'shield-alt'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/source/packages/com_mokowaas/admin/tmpl/automation/default.php b/source/packages/com_mokowaas/admin/tmpl/automation/default.php new file mode 100644 index 00000000..e9fd493d --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/automation/default.php @@ -0,0 +1,141 @@ +rules; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveAutomation&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteAutomation&format=json'); +$toggleUrl = Route::_('index.php?option=com_mokowaas&task=display.toggleAutomation&format=json'); + +$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)']; +?> + +
+
+

Automation Rules

+ +
+ + + conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> +
+
+
+
+
+
+ enabled ? 'checked' : ''; ?>> +
+ title); ?> + trigger_event] ?? $r->trigger_event; ?> +
+
+ IF + $c): ?> + 0 ? ' AND ' : ''; ?> + + THEN + + = + +
+
+ +
+
+
+ + + +
No automation rules. Click "Add Rule" to create one.
+ +
+ + + + + diff --git a/source/packages/com_mokowaas/admin/tmpl/canned/default.php b/source/packages/com_mokowaas/admin/tmpl/canned/default.php new file mode 100644 index 00000000..49a2bb31 --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/canned/default.php @@ -0,0 +1,107 @@ +responses; +$categories = $this->categories; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCanned&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCanned&format=json'); +?> + +
+
+

Canned Responses

+ +
+ + +
+
+
+
+ title); ?> +

body, 0, 150)); ?>

+
+ +
+
+
+ + + +
No canned responses yet. Click "Add Response" to create one.
+ +
+ + + + + diff --git a/source/packages/com_mokowaas/admin/tmpl/categories/default.php b/source/packages/com_mokowaas/admin/tmpl/categories/default.php new file mode 100644 index 00000000..d52cb7ac --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/categories/default.php @@ -0,0 +1,126 @@ +categories; +$users = $this->users; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCategory&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCategory&format=json'); +?> + +
+
+

Categories

+ +
+ +
+
+ + + + + + + + + + + + + + +
TitleSLA ResponseSLA ResolutionAuto-AssignActive
min min + + + published ? 'checked' : ''; ?>> + + + +
+
+
+
+ + diff --git a/source/packages/com_mokowaas/admin/tmpl/cleanup/default.php b/source/packages/com_mokowaas/admin/tmpl/cleanup/default.php new file mode 100644 index 00000000..073c623d --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/cleanup/default.php @@ -0,0 +1,63 @@ +dirs; +$token = Session::getFormToken(); +$cleanUrl = Route::_('index.php?option=com_mokowaas&task=display.cleanDirectory&format=json'); + +$dirKeys = ['site_cache', 'admin_cache', 'tmp', 'logs']; +$totalMb = 0; +$totalFiles = 0; +foreach ($dirs as $d) { $totalMb += $d->size_mb; $totalFiles += $d->files; } +?> + +
+
+
MBTotal Size
+
Total Files
+
+ +
+ $d): ?> +
+
+
+
label); ?>
+

size_mb, 1); ?> MB

+

files); ?> files

+ writable): ?> + Not writable + + + +
+
+
+ +
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php b/source/packages/com_mokowaas/admin/tmpl/dashboard/default.php similarity index 51% rename from src/packages/com_mokowaas/admin/tmpl/dashboard/default.php rename to source/packages/com_mokowaas/admin/tmpl/dashboard/default.php index c8922884..8c007c6e 100644 --- a/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php +++ b/source/packages/com_mokowaas/admin/tmpl/dashboard/default.php @@ -19,6 +19,9 @@ $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(); @@ -63,29 +66,118 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; offline): ?> +
+ + escape($_SERVER['REMOTE_ADDR'] ?? ''); ?> +
+ + +
+ 'icon-cogs', + 'mod_mokowaas_cpanel' => 'icon-tachometer-alt', + 'mod_mokowaas_menu' => 'icon-bars', + 'mod_mokowaas_cache' => 'icon-bolt', + 'mod_mokowaas_categories' => 'icon-folder', + ]; + foreach ($mokoExts as $ext): + $icon = $extIcons[$ext->element] ?? 'icon-puzzle-piece'; + $label = str_replace(['mod_mokowaas_', 'com_mokowaas'], ['', 'Component'], $ext->element); + $label = ucfirst($label ?: 'Component'); + ?> +
+ + escape($label); ?> + escape($ext->version); ?> +
+ +
+ + + + +
+ + Akeeba data detected β€” import into MokoWaaS: + + + + + + +
+ +
-
+
-
+ - @@ -118,10 +210,14 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; escape($plugin->version); ?>
-

escape($plugin->description); ?>

+

escape($plugin->description); ?>

protected): ?> + configure_only): ?> + + enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?> +
enabled ? 'checked' : ''; ?>> -
@@ -149,8 +245,28 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
- -
+ +
+ + +
+
+ WAF Activity (14 days) +
+
+ +
+
+ + +
+
+ Login Activity (14 days) +
+
+ +
+
@@ -165,16 +281,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; - escape($upd->name); ?> - escape($upd->current_version); ?> - escape($upd->version); ?> + escape($upd->name); ?> + escape($upd->current_version); ?> + escape($upd->version); ?>
-
+
All extensions up to date
@@ -193,19 +309,19 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; - escape(mb_substr($item->title, 0, 30)); ?> - escape($item->username ?? ''); ?> - checked_out_time, 'M d H:i'); ?> + escape(mb_substr($item->title, 0, 30)); ?> + escape($item->username ?? ''); ?> + checked_out_time, 'M d H:i'); ?>
-
+
No checked out items
@@ -224,16 +340,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; - escape($block->ip); ?> - escape($block->rule); ?> - created, 'M d H:i'); ?> + escape($block->ip); ?> + escape($block->rule); ?> + created, 'M d H:i'); ?>
-
+
No recent blocks
@@ -251,19 +367,85 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; - escape($login->username ?? ''); ?> - escape($login->ip_address ?? ''); ?> - log_date, 'M d H:i'); ?> + escape($login->username ?? ''); ?> + escape($login->ip_address ?? ''); ?> + log_date, 'M d H:i'); ?>
-
No login activity recorded
+
No login activity recorded
+ +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); +?> + + + diff --git a/source/packages/com_mokowaas/admin/tmpl/database/default.php b/source/packages/com_mokowaas/admin/tmpl/database/default.php new file mode 100644 index 00000000..0d0c9357 --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/database/default.php @@ -0,0 +1,72 @@ +tableData; +$tables = $data['tables'] ?? []; +$token = Session::getFormToken(); +$optimizeUrl = Route::_('index.php?option=com_mokowaas&task=display.optimizeDb&format=json'); +$repairUrl = Route::_('index.php?option=com_mokowaas&task=display.repairDb&format=json'); +$purgeUrl = Route::_('index.php?option=com_mokowaas&task=display.purgeSessions&format=json'); +?> + +
+
+
Tables
+
MBTotal Size
+
KBOverhead
+
+
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + +
TableEngineRowsSizeOverhead
name); ?>engine); ?>rows); ?>size_mb; ?> MBoverhead_kb > 0 ? $t->overhead_kb . ' KB' : 'β€”'; ?>
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/extensions/default.php b/source/packages/com_mokowaas/admin/tmpl/extensions/default.php similarity index 74% rename from src/packages/com_mokowaas/admin/tmpl/extensions/default.php rename to source/packages/com_mokowaas/admin/tmpl/extensions/default.php index cdaa6850..4ffca746 100644 --- a/src/packages/com_mokowaas/admin/tmpl/extensions/default.php +++ b/source/packages/com_mokowaas/admin/tmpl/extensions/default.php @@ -25,8 +25,9 @@ foreach ($packages as $pkg) } $statusBadge = [ - 'installed' => ['bg-success', 'Installed'], - 'not_installed' => ['bg-secondary', 'Not Installed'], + 'installed' => ['bg-success', 'Installed'], + 'update_available' => ['bg-warning text-dark', 'Update Available'], + 'not_installed' => ['bg-secondary', 'Not Installed'], ]; ?> @@ -63,6 +64,9 @@ $statusBadge = [
local_version): ?> vlocal_version); ?> + remote_version && $pkg->status === 'update_available'): ?> + → remote_version); ?> + remote_version): ?> Latest: remote_version); ?> @@ -73,7 +77,16 @@ $statusBadge = [ - download_url && $pkg->status === 'not_installed'): ?> + download_url && $pkg->status === 'update_available'): ?> + + download_url && $pkg->status === 'not_installed'): ?> + +
+ + + + + + +
+
+
NginX Configuration Snippet
+
+ +
+ +
+
+ + +
+
+
Current .htaccess on Disk
+
+ +
+
+
+ + + + diff --git a/source/packages/com_mokowaas/admin/tmpl/privacy/default.php b/source/packages/com_mokowaas/admin/tmpl/privacy/default.php new file mode 100644 index 00000000..9fd993e6 --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/privacy/default.php @@ -0,0 +1,267 @@ +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', +]; +?> + +
+ +
+
+
+ pending_requests; ?> + Pending Requests +
+
+
+
+ total_requests; ?> + Total Requests +
+
+
+
+ consent_entries; ?> + Consent Entries +
+
+
+
+ policies_active; ?> + Active Policies +
+
+
+ + +
+
+ Create Data Request + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+ +
+
+
+ Data Subject Requests +
+ + + +
+
+ +
No data requests found.
+ +
+ + + + + + + + + + + + + + + +
#UserTypeStatusCreatedProcessedActions
id; ?>escape($r->user_name ?? ''); ?>
escape($r->user_email ?? ''); ?>
type); ?>status); ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : 'β€”'; ?> + status === 'pending'): ?> +
+ + +
+ status === 'completed' && $r->type === 'export'): ?> + + +
+
+ +
+
+ + +
+
+
Retention Policies
+
+ + + + + + + + + + + + +
TypeDaysActionActive
escape($p->content_type); ?>retention_days; ?>action; ?>enabled ? 'Yes' : 'No'; ?>
+
+
+
+
+
+ + diff --git a/source/packages/com_mokowaas/admin/tmpl/ticket/default.php b/source/packages/com_mokowaas/admin/tmpl/ticket/default.php new file mode 100644 index 00000000..e7ceb8ee --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/ticket/default.php @@ -0,0 +1,198 @@ +ticket; +$canned = $this->cannedResponses; +$token = Session::getFormToken(); + +$statusBadge = [ + 'open' => 'bg-primary', 'in_progress' => 'bg-info', + 'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary', +]; +$priorityBadge = [ + 'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger', +]; +?> + +
+ +
+ +
+
+
+ escape($t->created_by_name); ?> + created, 'M d, Y H:i'); ?> +
+ Original +
+
escape($t->body)); ?>
+
+ + + replies as $reply): ?> +
+
+
+ escape($reply->user_name ?? 'System'); ?> + created, 'M d, Y H:i'); ?> +
+ is_internal): ?> + Internal Note + +
+
escape($reply->body)); ?>
+
+ + + +
+
Reply
+
+ +
+ +
+ + +
+ + +
+
+
+
+ + +
+
+
Details
+
+ + + + + + + + resolved): ?> + closed): ?> + +
Statusstatus)); ?>
Prioritypriority); ?>
Categoryescape($t->category_title ?? 'β€”'); ?>
Created Byescape($t->created_by_name); ?>
escape($t->created_by_email ?? ''); ?>
Assigned Toescape($t->assigned_to_name ?? 'Unassigned'); ?>
Createdcreated, 'M d, Y H:i'); ?>
Resolvedresolved, 'M d, Y H:i'); ?>
Closedclosed, 'M d, Y H:i'); ?>
Repliesreply_count; ?>
+
+
+ + + sla_response_due || $t->sla_resolution_due): ?> +
+
SLA
+
+ sla_response_due): ?> +
+ Response Due
+ sla_responded && strtotime($t->sla_response_due) < time(); + ?> + + sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?> + + +
+ + sla_resolution_due): ?> +
+ Resolution Due
+ status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time(); + ?> + + status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?> + + +
+ +
+
+ + + +
+
Actions
+
+ 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?> + status): ?> + + + +
+
+
+
+ + diff --git a/source/packages/com_mokowaas/admin/tmpl/tickets/default.php b/source/packages/com_mokowaas/admin/tmpl/tickets/default.php new file mode 100644 index 00000000..33c7f502 --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/tickets/default.php @@ -0,0 +1,291 @@ +tickets; +$categories = $this->categories; +$counts = $this->statusCounts; +$overdue = $this->overdue; +$atsAvailable = $this->atsAvailable; +$token = Session::getFormToken(); + +$statusBadge = [ + 'open' => 'bg-primary', + 'in_progress' => 'bg-info', + 'waiting' => 'bg-warning text-dark', + 'resolved' => 'bg-success', + 'closed' => 'bg-secondary', +]; + +$priorityBadge = [ + 'low' => 'bg-secondary', + 'normal' => 'bg-primary', + 'high' => 'bg-warning text-dark', + 'urgent' => 'bg-danger', +]; +?> + +
+ +
+
open; ?>Open
+
in_progress; ?>In Progress
+
waiting; ?>Waiting
+
resolved; ?>Resolved
+
closed; ?>Closed
+ 0): ?> +
SLA Overdue
+ +
+ + +
+
+ + + + +
+
+ + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger'; + elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger'; + elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning'; + ?> + + + + + + + + + + + + + + +
#SubjectStatusPriorityCategoryCreated ByAssigned ToCreatedSLA
No tickets found.
id; ?>escape(mb_substr($t->subject, 0, 60)); ?>status)); ?>priority); ?>escape($t->category_title ?? 'β€”'); ?>escape($t->created_by_name ?? ''); ?>assigned_to_name ? $this->escape($t->assigned_to_name) : 'Unassigned'; ?>created, 'M d H:i'); ?> + sla_response_due && !$t->sla_responded): ?> + sla_response_due, 'M d H:i'); ?> + sla_resolution_due): ?> + sla_resolution_due, 'M d H:i'); ?> + β€” +
+
+
+
+ + + + + diff --git a/source/packages/com_mokowaas/admin/tmpl/waflog/default.php b/source/packages/com_mokowaas/admin/tmpl/waflog/default.php new file mode 100644 index 00000000..4fab7ab2 --- /dev/null +++ b/source/packages/com_mokowaas/admin/tmpl/waflog/default.php @@ -0,0 +1,212 @@ +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', +]; +?> + +
+ +
+ +
+ rule); ?> + cnt); ?> +
+ +
+ Total + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
+ + +
+
+ blocked requests + +
+
+ + + + + + + + + + + + + + + + + + + + + +
TimeIPRuleURIDetailUser Agent
No blocked requests found.
created, 'M d H:i:s'); ?>ip); ?>rule); ?>uri, 0, 60)); ?>detail, 0, 50)); ?>user_agent, 0, 40)); ?> + +
+
+ + 1): ?> + + +
+
+ + +
+
+
Top Blocked IPs
+
+ + + + + + + + + + + + +
IPBlocksLast
ip); ?>cnt; ?>last_seen, 'M d'); ?> + +
+
+
+
+
+
+ + diff --git a/src/packages/com_mokowaas/api/src/Controller/CacheController.php b/source/packages/com_mokowaas/api/src/Controller/CacheController.php similarity index 97% rename from src/packages/com_mokowaas/api/src/Controller/CacheController.php rename to source/packages/com_mokowaas/api/src/Controller/CacheController.php index 8b4a4f44..f5e25409 100644 --- a/src/packages/com_mokowaas/api/src/Controller/CacheController.php +++ b/source/packages/com_mokowaas/api/src/Controller/CacheController.php @@ -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(); diff --git a/src/packages/com_mokowaas/api/src/Controller/DashboardController.php b/source/packages/com_mokowaas/api/src/Controller/DashboardController.php similarity index 100% rename from src/packages/com_mokowaas/api/src/Controller/DashboardController.php rename to source/packages/com_mokowaas/api/src/Controller/DashboardController.php diff --git a/src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php b/source/packages/com_mokowaas/api/src/Controller/ExtensionsController.php similarity index 100% rename from src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php rename to source/packages/com_mokowaas/api/src/Controller/ExtensionsController.php diff --git a/src/packages/com_mokowaas/api/src/Controller/HealthController.php b/source/packages/com_mokowaas/api/src/Controller/HealthController.php similarity index 100% rename from src/packages/com_mokowaas/api/src/Controller/HealthController.php rename to source/packages/com_mokowaas/api/src/Controller/HealthController.php diff --git a/src/packages/com_mokowaas/api/src/Controller/InstallController.php b/source/packages/com_mokowaas/api/src/Controller/InstallController.php similarity index 99% rename from src/packages/com_mokowaas/api/src/Controller/InstallController.php rename to source/packages/com_mokowaas/api/src/Controller/InstallController.php index 8b36c42a..e408fe46 100644 --- a/src/packages/com_mokowaas/api/src/Controller/InstallController.php +++ b/source/packages/com_mokowaas/api/src/Controller/InstallController.php @@ -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(); diff --git a/src/packages/com_mokowaas/api/src/Controller/PluginsController.php b/source/packages/com_mokowaas/api/src/Controller/PluginsController.php similarity index 99% rename from src/packages/com_mokowaas/api/src/Controller/PluginsController.php rename to source/packages/com_mokowaas/api/src/Controller/PluginsController.php index a0b84be2..cfc9788a 100644 --- a/src/packages/com_mokowaas/api/src/Controller/PluginsController.php +++ b/source/packages/com_mokowaas/api/src/Controller/PluginsController.php @@ -104,7 +104,7 @@ class PluginsController extends BaseController * * @return void */ - public function execute(): void + public function execute($task = 'plugins'): void { $app = Factory::getApplication(); $user = $app->getIdentity(); diff --git a/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php b/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php new file mode 100644 index 00000000..8f66a1c5 --- /dev/null +++ b/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php @@ -0,0 +1,236 @@ +getIdentity(); + + if (!$user->authorise('core.manage', 'com_mokowaas')) + { + $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', 'mokowaas'); + + 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('mokowaas')) + ->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/mokowaas_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(); + } +} diff --git a/source/packages/com_mokowaas/api/src/Controller/RemoteLoginController.php b/source/packages/com_mokowaas/api/src/Controller/RemoteLoginController.php new file mode 100644 index 00000000..b15e4353 --- /dev/null +++ b/source/packages/com_mokowaas/api/src/Controller/RemoteLoginController.php @@ -0,0 +1,173 @@ +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', 'mokowaas'); + + if (!$plugin) + { + $this->sendJson(503, ['error' => 'MokoWaaS 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/mokowaas_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?mokowaas_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 MokoWaaSHelper if available + $helperFile = JPATH_PLUGINS . '/system/mokowaas/Helper/MokoWaaSHelper.php'; + + if (file_exists($helperFile)) + { + require_once $helperFile; + + if (method_exists(\Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper::class, 'getMasterUsernames')) + { + return \Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper::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(); + } +} diff --git a/src/packages/com_mokowaas/api/src/Controller/ResetController.php b/source/packages/com_mokowaas/api/src/Controller/ResetController.php similarity index 91% rename from src/packages/com_mokowaas/api/src/Controller/ResetController.php rename to source/packages/com_mokowaas/api/src/Controller/ResetController.php index 0f80f5e2..4551aa76 100644 --- a/src/packages/com_mokowaas/api/src/Controller/ResetController.php +++ b/source/packages/com_mokowaas/api/src/Controller/ResetController.php @@ -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(); @@ -90,18 +90,18 @@ class ResetController extends BaseController */ private function createService(Registry $params) { - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php'; + $serviceFile = JPATH_PLUGINS . '/task/mokowaasdemo/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\MokoWaaSDemo\Service\DemoResetService($media); } /** diff --git a/src/packages/com_mokowaas/api/src/Controller/SnapshotController.php b/source/packages/com_mokowaas/api/src/Controller/SnapshotController.php similarity index 90% rename from src/packages/com_mokowaas/api/src/Controller/SnapshotController.php rename to source/packages/com_mokowaas/api/src/Controller/SnapshotController.php index 0046fac0..3729b95c 100644 --- a/src/packages/com_mokowaas/api/src/Controller/SnapshotController.php +++ b/source/packages/com_mokowaas/api/src/Controller/SnapshotController.php @@ -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(); @@ -118,11 +118,11 @@ class SnapshotController extends BaseController */ private function createService() { - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php'; + $serviceFile = JPATH_PLUGINS . '/task/mokowaasdemo/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; @@ -132,7 +132,7 @@ class SnapshotController extends BaseController $media = (bool) $params->get('demo_snapshot_include_media', 1); - return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media); + return new \Moko\Plugin\Task\MokoWaaSDemo\Service\DemoResetService($media); } /** diff --git a/src/packages/com_mokowaas/api/src/Controller/SyncController.php b/source/packages/com_mokowaas/api/src/Controller/SyncController.php similarity index 89% rename from src/packages/com_mokowaas/api/src/Controller/SyncController.php rename to source/packages/com_mokowaas/api/src/Controller/SyncController.php index 3e89f09c..93809f52 100644 --- a/src/packages/com_mokowaas/api/src/Controller/SyncController.php +++ b/source/packages/com_mokowaas/api/src/Controller/SyncController.php @@ -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(); @@ -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/mokowaassync/src/Service/ContentSyncService.php'; require_once $serviceFile; - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); + $service = new \Moko\Plugin\Task\MokoWaaSSync\Service\ContentSyncService(); $result = $service->syncAllTargets($targets); $this->sendJson(200, $result); diff --git a/src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php b/source/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php similarity index 88% rename from src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php rename to source/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php index 60a734db..d888f7fb 100644 --- a/src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php +++ b/source/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php @@ -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(); @@ -52,10 +52,10 @@ class SyncReceiveController extends BaseController return; } - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncReceiver.php'; + $serviceFile = JPATH_PLUGINS . '/task/mokowaassync/src/Service/ContentSyncReceiver.php'; require_once $serviceFile; - $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); + $receiver = new \Moko\Plugin\Task\MokoWaaSSync\Service\ContentSyncReceiver(); $result = $receiver->receive($payload); $this->sendJson(200, $result); diff --git a/src/packages/com_mokowaas/api/src/Controller/UpdateController.php b/source/packages/com_mokowaas/api/src/Controller/UpdateController.php similarity index 97% rename from src/packages/com_mokowaas/api/src/Controller/UpdateController.php rename to source/packages/com_mokowaas/api/src/Controller/UpdateController.php index d74e9c2f..bb48efba 100644 --- a/src/packages/com_mokowaas/api/src/Controller/UpdateController.php +++ b/source/packages/com_mokowaas/api/src/Controller/UpdateController.php @@ -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(); diff --git a/src/packages/com_mokowaas/media/css/dashboard.css b/source/packages/com_mokowaas/media/css/dashboard.css similarity index 100% rename from src/packages/com_mokowaas/media/css/dashboard.css rename to source/packages/com_mokowaas/media/css/dashboard.css diff --git a/src/packages/com_mokowaas/media/js/dashboard.js b/source/packages/com_mokowaas/media/js/dashboard.js similarity index 75% rename from src/packages/com_mokowaas/media/js/dashboard.js rename to source/packages/com_mokowaas/media/js/dashboard.js index df8433ed..e6aa671c 100644 --- a/src/packages/com_mokowaas/media/js/dashboard.js +++ b/source/packages/com_mokowaas/media/js/dashboard.js @@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () { }); }); } + + // Akeeba import buttons + ['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) { + var btn = document.getElementById(id); + if (!btn) return; + btn.addEventListener('click', function() { + var el = this; + if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return; + el.disabled = true; + var origText = el.textContent; + el.textContent = ' Importing...'; + var fd = new FormData(); + fd.append(el.dataset.token, '1'); + fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) + .then(function(r){return r.json()}) + .then(function(d){ + if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); } + else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; } + }) + .catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; }); + }); + }); }); diff --git a/source/packages/com_mokowaas/mokowaas.xml b/source/packages/com_mokowaas/mokowaas.xml new file mode 100644 index 00000000..c16922d4 --- /dev/null +++ b/source/packages/com_mokowaas/mokowaas.xml @@ -0,0 +1,80 @@ + + + + MokoWaaS + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. + + Moko\Component\MokoWaaS + + + MokoWaaS + + COM_MOKOWAAS_MENU_DASHBOARD + COM_MOKOWAAS_MENU_EXTENSIONS + COM_MOKOWAAS_MENU_TICKETS + COM_MOKOWAAS_MENU_HTACCESS + COM_MOKOWAAS_MENU_PRIVACY + COM_MOKOWAAS_MENU_WAFLOG + COM_MOKOWAAS_MENU_DATABASE + COM_MOKOWAAS_MENU_CLEANUP + COM_MOKOWAAS_MENU_PLUGINS + COM_MOKOWAAS_MENU_UPDATES + COM_MOKOWAAS_MENU_CHECKIN + COM_MOKOWAAS_MENU_CACHE + + + access.xml + catalog.xml + config.xml + language + services + sql + src + tmpl + + + en-GB/com_mokowaas.sys.ini + + + + + language + services + src + tmpl + + + + admin/sql/install.mysql.sql + + + + + src + + + + + css + js + + diff --git a/source/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini b/source/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini new file mode 100644 index 00000000..3047c85e --- /dev/null +++ b/source/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini @@ -0,0 +1,11 @@ +; MokoWaaS Customer Portal - Language Strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOWAAS_PORTAL_TITLE="Support Portal" +COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets" +COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket" +COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket" +COM_MOKOWAAS_PORTAL_REPLY="Send Reply" +COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet." +COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal." diff --git a/source/packages/com_mokowaas/site/services/provider.php b/source/packages/com_mokowaas/site/services/provider.php new file mode 100644 index 00000000..cb74ca34 --- /dev/null +++ b/source/packages/com_mokowaas/site/services/provider.php @@ -0,0 +1,38 @@ +registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS')); + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS')); + + $container->set( + ComponentInterface::class, + function (Container $container) { + $component = new \Joomla\CMS\Extension\MVCComponent( + $container->get(ComponentDispatcherFactoryInterface::class) + ); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + + return $component; + } + ); + } +}; diff --git a/source/packages/com_mokowaas/site/src/Controller/DisplayController.php b/source/packages/com_mokowaas/site/src/Controller/DisplayController.php new file mode 100644 index 00000000..1018e9eb --- /dev/null +++ b/source/packages/com_mokowaas/site/src/Controller/DisplayController.php @@ -0,0 +1,267 @@ +getIdentity(); + + if ($user->guest) + { + Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning'); + Factory::getApplication()->redirect(Route::_( + 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'), + false + )); + + return; + } + + return parent::display($cachable, $urlparams); + } + + /** + * Submit a new ticket. + */ + public function submitTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $input = Factory::getApplication()->getInput(); + + // Use admin TicketsModel + $model = $this->getModel('Tickets', 'Administrator'); + + $this->jsonResponse($model->createTicket([ + 'subject' => $input->getString('subject', ''), + 'body' => $input->getRaw('body', ''), + 'priority' => $input->getString('priority', 'normal'), + 'category_id' => $input->getInt('category_id', 0), + ])); + } + + /** + * Submit a reply. + */ + public function submitReply() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + $input = Factory::getApplication()->getInput(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $ticketId = $input->getInt('ticket_id', 0); + $model = $this->getModel('Tickets', 'Administrator'); + $ticket = $model->getTicket($ticketId); + + if (!$ticket) + { + $this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']); + return; + } + + // Customers can only reply to their own tickets; staff can reply to any + if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user)) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + // Staff replies from frontend are not internal notes + $this->jsonResponse($model->addReply( + $ticketId, + $input->getRaw('body', ''), + false + )); + } + + /** + * Update ticket status (staff/manager only from frontend). + */ + public function updateStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if (!$this->isStaff($user)) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + $input = Factory::getApplication()->getInput(); + $model = $this->getModel('Tickets', 'Administrator'); + + $this->jsonResponse($model->updateStatus( + $input->getInt('ticket_id', 0), + $input->getString('status', '') + )); + } + + /** + * Assign a ticket (manager only from frontend). + */ + public function assignTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas')) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $assignTo = $input->getInt('assigned_to', 0); + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + + $this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']); + } + catch (\Throwable $e) + { + $this->jsonResponse(['success' => false, 'message' => $e->getMessage()]); + return; + } + } + + /** + * Submit a data privacy request from frontend. + */ + public function submitDataRequest() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $type = Factory::getApplication()->getInput()->getString('type', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + + $this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal')); + } + + /** + * Check if user is support staff (can manage tickets beyond their own). + */ + private function isStaff($user): bool + { + if ($user->guest) + { + return false; + } + + // Super admins always staff + if ($user->authorise('core.admin')) + { + return true; + } + + // Anyone with mokowaas.tickets ACL on the component is staff + return $user->authorise('mokowaas.tickets', 'com_mokowaas'); + } + + /** + * Search KB articles via Smart Search (com_finder). + */ + public function searchKb() + { + $query = Factory::getApplication()->getInput()->getString('q', ''); + + if (strlen($query) < 3) + { + $this->jsonResponse(['results' => []]); + } + + try + { + $db = Factory::getDbo(); + $escaped = $db->quote('%' . $db->escape($query, true) . '%'); + + $results = $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('l.link_id'), + $db->quoteName('l.title'), + $db->quoteName('l.url'), + $db->quoteName('l.description'), + ]) + ->from($db->quoteName('#__finder_links', 'l')) + ->where($db->quoteName('l.published') . ' = 1') + ->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped + . ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')') + ->order($db->quoteName('l.title') . ' ASC') + ->setLimit(8) + )->loadObjectList() ?: []; + + foreach ($results as $r) + { + $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + } + + $this->jsonResponse(['results' => $results]); + } + catch (\Throwable $e) + { + $this->jsonResponse(['results' => []]); + } + } + + private function jsonResponse(array $data): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json'); + echo json_encode($data); + $app->close(); + } +} diff --git a/source/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php b/source/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..a6b70082 --- /dev/null +++ b/source/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php @@ -0,0 +1,68 @@ +getIdentity(); + + if ($user->guest) + { + Factory::getApplication()->redirect(Route::_( + 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'), + false + )); + + return; + } + + $db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface'); + + // Get user's data requests + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_data_requests')) + ->where($db->quoteName('user_id') . ' = ' . (int) $user->id) + ->order($db->quoteName('created') . ' DESC'); + + try + { + $db->setQuery($query); + $this->requests = $db->loadObjectList() ?: []; + } + catch (\Throwable $e) + { + $this->requests = []; + } + + // Get consent history + try + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . (int) $user->id) + ->order($db->quoteName('created') . ' DESC') + ->setLimit(20) + ); + $this->consent = $db->loadObjectList() ?: []; + } + catch (\Throwable $e) + { + $this->consent = []; + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php b/source/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php new file mode 100644 index 00000000..4a4289e6 --- /dev/null +++ b/source/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php @@ -0,0 +1,84 @@ +get('Joomla\Database\DatabaseInterface'); + $user = Factory::getApplication()->getIdentity(); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas'); + $this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas'); + + // Get ticket β€” staff see any, customers see only their own + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t') . '.*', + $db->quoteName('c.title', 'category_title'), + $db->quoteName('u.name', 'created_by_name'), + $db->quoteName('u.email', 'created_by_email'), + $db->quoteName('a.name', 'assigned_to_name'), + ]) + ->from($db->quoteName('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to') + ->where($db->quoteName('t.id') . ' = ' . $id); + + if (!$this->isStaff) + { + $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id); + } + + $db->setQuery($query); + $this->ticket = $db->loadObject(); + + if (!$this->ticket) + { + Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); + Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false)); + + return; + } + + // Load replies β€” staff see internal notes, customers don't + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r') . '.*', + $db->quoteName('u.name', 'user_name'), + ]) + ->from($db->quoteName('#__mokowaas_ticket_replies', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->where($db->quoteName('r.ticket_id') . ' = ' . $id); + + if (!$this->isStaff) + { + $query->where($db->quoteName('r.is_internal') . ' = 0'); + } + + $query->order($db->quoteName('r.created') . ' ASC'); + $db->setQuery($query); + $this->ticket->replies = $db->loadObjectList() ?: []; + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php b/source/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php new file mode 100644 index 00000000..5988fba9 --- /dev/null +++ b/source/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php @@ -0,0 +1,75 @@ +get('Joomla\Database\DatabaseInterface'); + $user = Factory::getApplication()->getIdentity(); + + $this->isStaff = $user->authorise('core.admin') + || $user->authorise('mokowaas.tickets', 'com_mokowaas'); + + // Staff see all tickets, customers see their own + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t.id'), + $db->quoteName('t.subject'), + $db->quoteName('t.status'), + $db->quoteName('t.priority'), + $db->quoteName('t.created'), + $db->quoteName('t.assigned_to'), + $db->quoteName('c.title', 'category_title'), + $db->quoteName('u.name', 'created_by_name'), + $db->quoteName('a.name', 'assigned_to_name'), + ]) + ->from($db->quoteName('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to'); + + if (!$this->isStaff) + { + $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id); + } + + $filterStatus = Factory::getApplication()->getInput()->getString('filter_status', ''); + + if ($filterStatus) + { + $query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus)); + } + + $query->order($db->quoteName('t.created') . ' DESC')->setLimit(50); + $db->setQuery($query); + $this->tickets = $db->loadObjectList() ?: []; + + // Categories for new ticket form + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('title')]) + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $this->categories = $db->loadObjectList() ?: []; + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokowaas/site/tmpl/privacy/default.php b/source/packages/com_mokowaas/site/tmpl/privacy/default.php new file mode 100644 index 00000000..f26b4e6a --- /dev/null +++ b/source/packages/com_mokowaas/site/tmpl/privacy/default.php @@ -0,0 +1,114 @@ +getIdentity(); +$requests = $this->requests; +$consent = $this->consent; +$token = Session::getFormToken(); + +$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied']; +$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary']; +?> + +
+

My Privacy & Data

+

Manage your personal data, download your information, or request account deletion.

+ + +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
My Data Requests
+
+ + + + + + + + + + + + +
TypeStatusSubmittedProcessed
type); ?>status] ?? $r->status; ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : 'β€”'; ?>
+
+
+ + + + +
+
Consent History
+
+ + + + + + + + + + + +
CategoryActionDate
category))); ?>action); ?>created, 'M d, Y H:i'); ?>
+
+
+ +
+ + diff --git a/source/packages/com_mokowaas/site/tmpl/ticket/default.php b/source/packages/com_mokowaas/site/tmpl/ticket/default.php new file mode 100644 index 00000000..7f84e579 --- /dev/null +++ b/source/packages/com_mokowaas/site/tmpl/ticket/default.php @@ -0,0 +1,241 @@ +ticket; +$isStaff = $this->isStaff; +$canAssign = $this->canAssign; +$token = Session::getFormToken(); +$userId = Factory::getApplication()->getIdentity()->id; + +$statusLabel = [ + 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response', + 'resolved' => 'Resolved', 'closed' => 'Closed', +]; +$statusClass = [ + 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning', + 'resolved' => 'success', 'closed' => 'secondary', +]; +?> + +
+ + +
+ +
+ + +
+
+
+
+

#id; ?> β€” subject); ?>

+ + category_title ?? 'General'); ?> + · created, 'M d, Y H:i'); ?> + · priority); ?> + + · By: created_by_name); ?> + + +
+ + status] ?? $t->status; ?> + +
+
+
+ + +
+
+ created_by_name); ?> + created, 'M d, Y H:i'); ?> +
+
body)); ?>
+
+ + + replies as $reply): ?> + user_id !== (int) $t->created_by); + $isInternal = (int) $reply->is_internal; + ?> +
+
+
+ user_name ?? 'Support'); ?> + Staff + Internal Note + created, 'M d, Y H:i'); ?> +
+
+
body)); ?>
+
+ + + + status, ['closed'])): ?> +
+
+
Reply
+
+ + + +
+ + + + +
+
+
+
+ status === 'closed'): ?> +
+ This ticket is closed. Open a new ticket if you need further help. +
+ +
+ + + +
+ +
+
Details
+
+
+
Status
+
status] ?? $t->status; ?>
+
Priority
+
priority); ?>
+
Category
+
category_title ?? 'β€”'); ?>
+
Submitted By
+
created_by_name); ?>
created_by_email ?? ''); ?>
+
Assigned To
+
assigned_to_name ?? 'Unassigned'); ?>
+
Created
+
created, 'M d H:i'); ?>
+
Replies
+
replies); ?>
+
+
+
+ + +
+
Change Status
+
+ 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?> + status): ?> + + + +
+
+ + + +
+
Assign
+
+ +
+
+ +
+ +
+
+ + diff --git a/source/packages/com_mokowaas/site/tmpl/tickets/default.php b/source/packages/com_mokowaas/site/tmpl/tickets/default.php new file mode 100644 index 00000000..8ed9e1a3 --- /dev/null +++ b/source/packages/com_mokowaas/site/tmpl/tickets/default.php @@ -0,0 +1,83 @@ +tickets; +$categories = $this->categories; +$isStaff = $this->isStaff; +$token = Session::getFormToken(); + +$statusLabel = [ + 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response', + 'resolved' => 'Resolved', 'closed' => 'Closed', +]; +$statusClass = [ + 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning', + 'resolved' => 'success', 'closed' => 'secondary', +]; +?> + +
+
+

+
+ + New Ticket + + +
+ + + +
+ +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#SubjectStatusPriorityCategorySubmitted ByAssigned ToDate
id; ?>subject, 0, 60)); ?>status] ?? $t->status; ?>priority); ?>category_title ?? 'β€”'); ?>created_by_name ?? ''); ?>assigned_to_name ?? 'Unassigned'); ?>created, 'M d, Y'); ?>
+
+ +
diff --git a/source/packages/com_mokowaas/site/tmpl/tickets/submit.php b/source/packages/com_mokowaas/site/tmpl/tickets/submit.php new file mode 100644 index 00000000..cc5da1b8 --- /dev/null +++ b/source/packages/com_mokowaas/site/tmpl/tickets/submit.php @@ -0,0 +1,204 @@ +categories; +$token = Session::getFormToken(); +$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); +$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json'); +$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id='); +$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets'); + +// Check if Smart Search has indexed content +$finderEnabled = false; +try { + $db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface'); + $db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1'); + $finderEnabled = (int) $db->loadResult() > 0; +} catch (\Throwable $e) {} +?> + +
+

Submit a Support Request

+ + + + + + + +
+
+
+
Ticket Details
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + My Tickets + +
+
+
+
+
+
+ + diff --git a/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini b/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini new file mode 100644 index 00000000..93b2aae6 --- /dev/null +++ b/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini @@ -0,0 +1,4 @@ +MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner" +MOD_MOKOWAAS_CACHE_DESC="One-click cache and temp cleaner in the admin status bar." +MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache" +MOD_MOKOWAAS_CACHE_CLEAR_TEMP="Clear Temp" diff --git a/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini b/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini new file mode 100644 index 00000000..25f62d28 --- /dev/null +++ b/source/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini @@ -0,0 +1,2 @@ +MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner" +MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)." diff --git a/source/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml b/source/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml new file mode 100644 index 00000000..4909e800 --- /dev/null +++ b/source/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml @@ -0,0 +1,24 @@ + + + mod_mokowaas_cache + Moko Consulting + 2026-06-04 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + MOD_MOKOWAAS_CACHE_DESC + Moko\Module\MokoWaaSCache + + + services + src + tmpl + + + + en-GB/mod_mokowaas_cache.ini + en-GB/mod_mokowaas_cache.sys.ini + + diff --git a/source/packages/mod_mokowaas_cache/services/provider.php b/source/packages/mod_mokowaas_cache/services/provider.php new file mode 100644 index 00000000..cf5c25c4 --- /dev/null +++ b/source/packages/mod_mokowaas_cache/services/provider.php @@ -0,0 +1,23 @@ +registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache')); + $container->registerServiceProvider(new Module()); + } +}; diff --git a/source/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000..b67aad8d --- /dev/null +++ b/source/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php @@ -0,0 +1,14 @@ + + + + +
+ Clear: + + Cache + + | + + Temp + +
+ + diff --git a/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.ini b/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.ini new file mode 100644 index 00000000..e01d1dc4 --- /dev/null +++ b/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.ini @@ -0,0 +1,24 @@ +MOD_MOKOWAAS_CATEGORIES="MokoWaaS Categories" +MOD_MOKOWAAS_CATEGORIES_DESC="Auto-discovers article categories and renders them as a collapsible tree menu. Ideal for knowledge base and help sections." + +MOD_MOKOWAAS_CATEGORIES_ROOT_LABEL="Root Category" +MOD_MOKOWAAS_CATEGORIES_ROOT_DESC="Select a parent category. Only its children (and their subcategories) will be displayed. Leave as All to show the entire category tree." +MOD_MOKOWAAS_CATEGORIES_ALL_CATEGORIES="- All Categories -" + +MOD_MOKOWAAS_CATEGORIES_DEPTH_LABEL="Maximum Depth" +MOD_MOKOWAAS_CATEGORIES_DEPTH_DESC="How many levels deep to display. 1 shows only top-level categories, 2 adds one level of subcategories, etc." + +MOD_MOKOWAAS_CATEGORIES_COUNT_LABEL="Show Article Count" +MOD_MOKOWAAS_CATEGORIES_COUNT_DESC="Display the number of published articles next to each category name." + +MOD_MOKOWAAS_CATEGORIES_EMPTY_LABEL="Show Empty Categories" +MOD_MOKOWAAS_CATEGORIES_EMPTY_DESC="Display categories that have no published articles. Only applies when Show Article Count is enabled." + +MOD_MOKOWAAS_CATEGORIES_MENUITEM_LABEL="Target Menu Item" +MOD_MOKOWAAS_CATEGORIES_MENUITEM_DESC="The menu item to use as the base for category links. This sets the Itemid parameter for proper template and menu highlighting." + +MOD_MOKOWAAS_CATEGORIES_ORDER_LABEL="Category Ordering" +MOD_MOKOWAAS_CATEGORIES_ORDER_DESC="How to sort categories within each level." +MOD_MOKOWAAS_CATEGORIES_ORDER_TREE="Tree Order (default)" +MOD_MOKOWAAS_CATEGORIES_ORDER_TITLE="Alphabetical" +MOD_MOKOWAAS_CATEGORIES_ORDER_CREATED="Date Created" diff --git a/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.sys.ini b/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.sys.ini new file mode 100644 index 00000000..4338989f --- /dev/null +++ b/source/packages/mod_mokowaas_categories/language/en-GB/mod_mokowaas_categories.sys.ini @@ -0,0 +1,2 @@ +MOD_MOKOWAAS_CATEGORIES="MokoWaaS Categories" +MOD_MOKOWAAS_CATEGORIES_DESC="Auto-discovers article categories and renders them as a collapsible tree menu. Ideal for knowledge base and help sections." diff --git a/source/packages/mod_mokowaas_categories/mod_mokowaas_categories.xml b/source/packages/mod_mokowaas_categories/mod_mokowaas_categories.xml new file mode 100644 index 00000000..c274468c --- /dev/null +++ b/source/packages/mod_mokowaas_categories/mod_mokowaas_categories.xml @@ -0,0 +1,76 @@ + + + mod_mokowaas_categories + Moko Consulting + 2026-06-06 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + MOD_MOKOWAAS_CATEGORIES_DESC + Moko\Module\MokoWaaSCategories + + + services + src + tmpl + + + + en-GB/mod_mokowaas_categories.ini + en-GB/mod_mokowaas_categories.sys.ini + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/source/packages/mod_mokowaas_categories/services/provider.php b/source/packages/mod_mokowaas_categories/services/provider.php new file mode 100644 index 00000000..0ef768ba --- /dev/null +++ b/source/packages/mod_mokowaas_categories/services/provider.php @@ -0,0 +1,25 @@ +registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCategories')); + $container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSCategories\\Administrator\\Helper')); + $container->registerServiceProvider(new Module()); + } +}; diff --git a/source/packages/mod_mokowaas_categories/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokowaas_categories/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000..07857915 --- /dev/null +++ b/source/packages/mod_mokowaas_categories/src/Dispatcher/Dispatcher.php @@ -0,0 +1,32 @@ +getHelperFactory()->getHelper('CategoriesHelper'); + + $data['categories'] = $helper->getCategories($params); + + return $data; + } +} diff --git a/source/packages/mod_mokowaas_categories/src/Helper/CategoriesHelper.php b/source/packages/mod_mokowaas_categories/src/Helper/CategoriesHelper.php new file mode 100644 index 00000000..c33da3ec --- /dev/null +++ b/source/packages/mod_mokowaas_categories/src/Helper/CategoriesHelper.php @@ -0,0 +1,148 @@ +get(DatabaseInterface::class); + + $rootId = (int) $params->get('root_category', 0); + $maxDepth = (int) $params->get('max_depth', 3); + $showEmpty = (int) $params->get('show_empty', 0); + $showCount = (int) $params->get('show_article_count', 1); + $ordering = $params->get('ordering', 'lft'); + $user = Factory::getApplication()->getIdentity(); + $accessLevels = $user->getAuthorisedViewLevels(); + + // Build base query + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.id'), + $db->quoteName('c.title'), + $db->quoteName('c.alias'), + $db->quoteName('c.parent_id'), + $db->quoteName('c.level'), + $db->quoteName('c.lft'), + $db->quoteName('c.rgt'), + $db->quoteName('c.description'), + ]) + ->from($db->quoteName('#__categories', 'c')) + ->where($db->quoteName('c.extension') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('c.published') . ' = 1') + ->whereIn($db->quoteName('c.access'), $accessLevels); + + // If a root category is set, constrain to its subtree + if ($rootId > 0) + { + $rootQuery = $db->getQuery(true) + ->select([$db->quoteName('lft'), $db->quoteName('rgt'), $db->quoteName('level')]) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . $rootId); + $db->setQuery($rootQuery); + $root = $db->loadObject(); + + if (!$root) + { + return []; + } + + $query->where($db->quoteName('c.lft') . ' > ' . (int) $root->lft) + ->where($db->quoteName('c.rgt') . ' < ' . (int) $root->rgt) + ->where($db->quoteName('c.level') . ' <= ' . ((int) $root->level + $maxDepth)); + } + else + { + // No root β€” show from level 1 (skip the virtual root) + $query->where($db->quoteName('c.level') . ' >= 1') + ->where($db->quoteName('c.level') . ' <= ' . $maxDepth); + } + + // Article count subquery + if ($showCount) + { + $countSub = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__content', 'a')) + ->where($db->quoteName('a.catid') . ' = ' . $db->quoteName('c.id')) + ->where($db->quoteName('a.state') . ' = 1'); + $query->select('(' . $countSub . ') AS ' . $db->quoteName('article_count')); + } + + // Ordering + $validOrders = ['lft', 'title', 'created_time']; + $orderCol = \in_array($ordering, $validOrders, true) ? $ordering : 'lft'; + $query->order($db->quoteName('c.' . $orderCol) . ' ASC'); + + $db->setQuery($query); + $categories = $db->loadObjectList() ?: []; + + // Filter empty categories if configured + if (!$showEmpty && $showCount) + { + $categories = array_filter($categories, function ($cat) { + return (int) $cat->article_count > 0; + }); + $categories = array_values($categories); + } + + // Build nested tree + return $this->buildTree($categories, $rootId); + } + + /** + * Build a nested tree from a flat list of categories. + * + * @param array $categories Flat list of category objects + * @param int $rootId Root category ID (0 for all) + * + * @return array Nested array with 'children' key on each node + */ + private function buildTree(array $categories, int $rootId): array + { + $map = []; + $tree = []; + + foreach ($categories as $cat) + { + $cat->children = []; + $map[$cat->id] = $cat; + } + + foreach ($categories as $cat) + { + $parentId = (int) $cat->parent_id; + + if (isset($map[$parentId])) + { + $map[$parentId]->children[] = $cat; + } + else + { + $tree[] = $cat; + } + } + + return $tree; + } +} diff --git a/source/packages/mod_mokowaas_categories/tmpl/default.php b/source/packages/mod_mokowaas_categories/tmpl/default.php new file mode 100644 index 00000000..81745845 --- /dev/null +++ b/source/packages/mod_mokowaas_categories/tmpl/default.php @@ -0,0 +1,138 @@ +get('show_article_count', 1)); +$menuItemId = (int) $params->get('menu_item_id', 0); + +if (empty($categories)) +{ + return; +} + +// Detect active category from current URL +$app = \Joomla\CMS\Factory::getApplication(); +$activeCatId = (int) $app->input->getInt('id', 0); +$currentView = $app->input->getCmd('view', ''); +$isCatView = \in_array($currentView, ['category', 'categories'], true); + +/** + * Build the link for a category. + */ +$buildLink = function (object $cat) use ($menuItemId): string { + $link = 'index.php?option=com_content&view=category&id=' . (int) $cat->id; + + if ($menuItemId) + { + $link .= '&Itemid=' . $menuItemId; + } + + return Route::_($link); +}; + +/** + * Check if a category or any of its descendants is the active category. + */ +$isActiveOrAncestor = function (object $cat) use ($activeCatId, $isCatView, &$isActiveOrAncestor): bool { + if (!$isCatView || !$activeCatId) + { + return false; + } + + if ((int) $cat->id === $activeCatId) + { + return true; + } + + foreach ($cat->children as $child) + { + if ($isActiveOrAncestor($child)) + { + return true; + } + } + + return false; +}; + +/** + * Render a category list recursively. + */ +$renderTree = function (array $categories, int $depth = 1) use ( + &$renderTree, $buildLink, $isActiveOrAncestor, $showCount, $activeCatId, $isCatView +): void { + foreach ($categories as $cat): + $hasChildren = !empty($cat->children); + $isActive = $isCatView && (int) $cat->id === $activeCatId; + $isAncestor = $hasChildren && $isActiveOrAncestor($cat); + $liClass = 'item mokowaas-cat-item mokowaas-cat-level-' . $depth; + + if ($isActive) + { + $liClass .= ' mm-active'; + } + + if ($hasChildren) + { + $liClass .= ' parent'; + } + + $aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown'); + + if ($isActive) + { + $aClass .= ' mm-active'; + } + + $collapseClass = 'collapse-cat-level-' . ($depth + 1) . ' mm-collapse'; + + if ($isAncestor || $isActive) + { + $collapseClass .= ' mm-show'; + } + + $count = isset($cat->article_count) ? (int) $cat->article_count : 0; + ?> +
  • + > + + title); ?> + + + + + +
      + children, $depth + 1); ?> +
    + +
  • + + + + + diff --git a/src/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.ini b/source/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.ini similarity index 100% rename from src/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.ini rename to source/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.ini diff --git a/src/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.sys.ini b/source/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.sys.ini similarity index 100% rename from src/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.sys.ini rename to source/packages/mod_mokowaas_cpanel/language/en-GB/mod_mokowaas_cpanel.sys.ini diff --git a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml b/source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml similarity index 99% rename from src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml rename to source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml index 4f12f37c..be8f768d 100644 --- a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml +++ b/source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 MOD_MOKOWAAS_CPANEL_DESC Moko\Module\MokoWaaSCpanel diff --git a/src/packages/mod_mokowaas_cpanel/services/provider.php b/source/packages/mod_mokowaas_cpanel/services/provider.php similarity index 100% rename from src/packages/mod_mokowaas_cpanel/services/provider.php rename to source/packages/mod_mokowaas_cpanel/services/provider.php diff --git a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php similarity index 75% rename from src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php rename to source/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php index d3b1c191..4c9b179a 100644 --- a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php +++ b/source/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php @@ -24,6 +24,18 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI { $data = parent::getLayoutData(); + // Hide on MokoWaaS dashboard β€” the dashboard has its own info panels + $app = Factory::getApplication(); + $option = $app->getInput()->get('option', ''); + $view = $app->getInput()->get('view', ''); + + if ($option === 'com_mokowaas' && ($view === '' || $view === 'dashboard')) + { + $data['hidden'] = true; + + return $data; + } + $db = Factory::getContainer()->get(DatabaseInterface::class); $helper = $this->getHelperFactory()->getHelper('CpanelHelper'); @@ -33,6 +45,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI $data['counts'] = $helper->getCounts($db); $data['disk'] = $helper->getDiskInfo(); $data['currentIp'] = $helper->getCurrentIp(); + $data['ssl'] = $helper->getSslStatus(); return $data; } diff --git a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php b/source/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php similarity index 64% rename from src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php rename to source/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php index 7160329e..87fff882 100644 --- a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php +++ b/source/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php @@ -87,10 +87,11 @@ class CpanelHelper public function getCounts(DatabaseInterface $db): object { $counts = (object) [ - 'articles' => 0, - 'users' => 0, - 'extensions' => 0, - 'updates' => 0, + 'articles' => 0, + 'users' => 0, + 'extensions' => 0, + 'updates' => 0, + 'moko_updates' => 0, ]; try @@ -106,6 +107,20 @@ class CpanelHelper $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__updates'))->where($db->quoteName('extension_id') . ' != 0')); $counts->updates = (int) $db->loadResult(); + + // MokoWaaS-specific updates + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__updates', 'u')) + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = u.extension_id') + ->where('(' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('pkg_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('com_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mod_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('mokoonyx') . ')') + ); + $counts->moko_updates = (int) $db->loadResult(); } catch (\Throwable $e) { @@ -136,4 +151,54 @@ class CpanelHelper { return $_SERVER['REMOTE_ADDR'] ?? ''; } + + /** + * Check SSL certificate expiry (#148). + * + * @return object|null {expires, days_remaining, warning} or null if check fails + */ + public function getSslStatus(): ?object + { + try + { + $host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST); + + if (empty($host)) + { + return null; + } + + $context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]); + $client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context); + + if (!$client) + { + return null; + } + + $params = stream_context_get_params($client); + fclose($client); + + $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? ''); + + if (empty($cert['validTo_time_t'])) + { + return null; + } + + $expires = $cert['validTo_time_t']; + $days = (int) floor(($expires - time()) / 86400); + + return (object) [ + 'expires' => date('Y-m-d', $expires), + 'days_remaining' => $days, + 'warning' => $days <= 30, + 'critical' => $days <= 7, + ]; + } + catch (\Throwable $e) + { + return null; + } + } } diff --git a/src/packages/mod_mokowaas_cpanel/tmpl/default.php b/source/packages/mod_mokowaas_cpanel/tmpl/default.php similarity index 70% rename from src/packages/mod_mokowaas_cpanel/tmpl/default.php rename to source/packages/mod_mokowaas_cpanel/tmpl/default.php index b3ed62f2..2af3a20e 100644 --- a/src/packages/mod_mokowaas_cpanel/tmpl/default.php +++ b/source/packages/mod_mokowaas_cpanel/tmpl/default.php @@ -12,6 +12,9 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; +// Hidden when on MokoWaaS dashboard (redundant info) +if (!empty($hidden)) return; + $siteInfo = $siteInfo ?? (object) []; $plugins = $plugins ?? []; @@ -55,25 +58,47 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== ?>
    - -
    - - - MokoWaaS - mokowaas_version ?? ''); ?> - debug)): ?> - Debug - - offline)): ?> - Offline - - - - - - - + +
    + + + MokoWaaS + mokowaas_version ?? ''); ?> + debug)): ?> + Debug + + offline)): ?> + Offline + + moko_updates ?? 0) > 0): ?> + + moko_updates; ?> MokoWaaS updatemoko_updates > 1 ? 's' : ''; ?> + + + updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?> + + updates - ($counts->moko_updates ?? 0); ?> updateupdates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?> + + + + + + + +
    +
    @@ -130,6 +155,12 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== + + + + SSL days_remaining; ?>d + + Jjoomla_version ?? ''); ?> / PHP php_version ?? ''); ?> diff --git a/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini b/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini new file mode 100644 index 00000000..dff9f13a --- /dev/null +++ b/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini @@ -0,0 +1 @@ +MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu" diff --git a/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini b/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini new file mode 100644 index 00000000..898a3832 --- /dev/null +++ b/source/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini @@ -0,0 +1,2 @@ +MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu" +MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu." diff --git a/source/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml b/source/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml new file mode 100644 index 00000000..571f575f --- /dev/null +++ b/source/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml @@ -0,0 +1,24 @@ + + + mod_mokowaas_menu + Moko Consulting + 2026-06-04 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + MokoWaaS admin sidebar menu β€” renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu. + Moko\Module\MokoWaaSMenu + + + services + src + tmpl + + + + en-GB/mod_mokowaas_menu.ini + en-GB/mod_mokowaas_menu.sys.ini + + diff --git a/source/packages/mod_mokowaas_menu/services/provider.php b/source/packages/mod_mokowaas_menu/services/provider.php new file mode 100644 index 00000000..67feaece --- /dev/null +++ b/source/packages/mod_mokowaas_menu/services/provider.php @@ -0,0 +1,18 @@ +registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu')); + $container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper')); + $container->registerServiceProvider(new Module()); + } +}; diff --git a/source/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000..b5d4dcc2 --- /dev/null +++ b/source/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php @@ -0,0 +1,14 @@ +getInput()->get('option', ''); +$currentView = $app->getInput()->get('view', ''); + +// ── Static MokoWaaS views ──────────────────────────────────────────── +$mokowaasItems = [ + ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokowaas'], + ['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokowaas&view=tickets'], + ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokowaas&view=extensions'], + ['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokowaas&view=htaccess'], + ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokowaas&view=privacy'], + ['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokowaas&view=waflog'], + ['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokowaas&view=database'], + ['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokowaas&view=cleanup'], + ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokowaas'], +]; + +// ── Auto-discover Moko component menus from #__menu ────────────────── +$mokoComponents = []; + +try +{ + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + + // Find all Moko component menu items (exclude com_mokowaas β€” handled above) + $db->setQuery( + "SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element" + . " FROM " . $db->quoteName('#__menu') . " m" + . " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id" + . " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1" + . " AND e.element LIKE 'com_moko%'" + . " AND e.element != 'com_mokowaas'" + . " AND e.enabled = 1" + . " ORDER BY e.element, m.level, m.lft" + ); + $menuItems = $db->loadObjectList() ?: []; + + // Load sys.ini language files for discovered components + $lang = Factory::getLanguage(); + $loadedLangs = []; + foreach ($menuItems as $m) + { + if (!isset($loadedLangs[$m->element])) + { + $lang->load($m->element . '.sys', JPATH_ADMINISTRATOR); + $lang->load($m->element, JPATH_ADMINISTRATOR); + $loadedLangs[$m->element] = true; + } + } + + // Group: level 1 = component parent, level 2 = children + foreach ($menuItems as $m) + { + if ((int) $m->level === 1) + { + $mokoComponents[$m->element] = [ + 'id' => $m->id, + 'title' => Text::_($m->title), + 'link' => $m->link, + 'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:puzzle-piece'), + 'element' => $m->element, + 'children' => [], + ]; + } + elseif ((int) $m->level === 2 && isset($mokoComponents[$m->element])) + { + $mokoComponents[$m->element]['children'][] = [ + 'title' => Text::_($m->title), + 'link' => $m->link, + 'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:cog'), + ]; + } + } +} +catch (\Throwable $e) +{ + // Silent β€” menu works without auto-discovered components +} + +// ── Determine active state ─────────────────────────────────────────── +$mokowaasActive = ($currentOption === 'com_mokowaas'); +$anyMokoActive = $mokowaasActive; + +foreach ($mokoComponents as $comp) +{ + $parsed = []; + parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed); + if (($parsed['option'] ?? '') === $currentOption) + { + $anyMokoActive = true; + } +} + +$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : ''); +$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : ''); +?> + + + + diff --git a/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php new file mode 100644 index 00000000..55918988 --- /dev/null +++ b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -0,0 +1,2327 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License (./LICENSE.md). + * + * FILE INFORMATION + * DEFGROUP: Joomla.Plugin + * INGROUP: MokoWaaS + * REPO: https://github.com/mokoconsulting-tech/mokowaas + * VERSION: 02.34.16 + * PATH: /src/Extension/MokoWaaS.php + * NOTE: Core system plugin for MokoWaaS admin tools suite + */ + +namespace Moko\Plugin\System\MokoWaaS\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\BootableExtensionInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Psr\Container\ContainerInterface; + +/** + * MokoWaaS Core System Plugin + * + * This plugin provides core coordination for the MokoWaaS admin tools suite. + * + * @since 01.04.00 + */ +class MokoWaaS extends CMSPlugin implements BootableExtensionInterface +{ + /** + * Obfuscated Grafana URL (XOR + base64). + * + * @var string + * @since 02.01.26 + */ + private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat'; + + /** + * Obfuscated master usernames (XOR 0x5A + base64). + * + * @var array + * @since 02.29.00 + */ + private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0=']; + + /** XOR key for decoding MASTER_KEYS. */ + private const MK = 0x5A; + + /** @var array|null Decoded master usernames cache. */ + private ?array $masterNames = null; + + /** + * Shared secret for heartbeat authentication. + * + * @var string + * @since 02.01.36 + */ + private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m'; + + /** + * Get the plugin version from the manifest XML. + * + * @return string Version string (e.g. '02.03.04') + * + * @since 02.03.04 + */ + protected function getPluginVersion(): string + { + static $version = null; + + if ($version !== null) + { + return $version; + } + + $manifestFile = JPATH_PLUGINS . '/system/mokowaas/mokowaas.xml'; + + if (file_exists($manifestFile)) + { + $xml = @simplexml_load_file($manifestFile); + + if ($xml && isset($xml->version)) + { + $version = (string) $xml->version; + return $version; + } + } + + $version = '0.0.0'; + return $version; + } + + /** + * Load the language file on instantiation. + * + * @var boolean + * @since 01.04.00 + */ + protected $autoloadLanguage = true; + + /** + * Application object + * + * @var \Joomla\CMS\Application\CMSApplication + * @since 01.04.00 + */ + protected $app; + + /** + * Boot the extension β€” runs BEFORE Joomla creates the session. + * + * Extends the Joomla session lifetime for trusted IPs so the + * session handler does not destroy the session before + * onAfterInitialise can run. + * + * @param ContainerInterface $container The DI container. + * + * @return void + * + * @since 02.11.00 + */ + public function boot(ContainerInterface $container): void + { + // Session lifetime for trusted IPs is now handled by the firewall plugin + } + + /** + * Event triggered after the framework has loaded and the application initialise method has been called. + * + * This method loads language override files from the plugin directory to rebrand Joomla + * with MokoWaaS identity. The override files replace core Joomla language strings. + * + * @return void + * + * @since 01.04.00 + */ + public function onAfterInitialise() + { + // Site alias handling + $this->handleSiteAlias(); + + // MokoWaaS API endpoints (run before routing) + $mokoAction = $this->app->input->get('mokowaas', ''); + + if ($mokoAction !== '') + { + $this->handleMokoApi($mokoAction); + } + + // Admin-only features + if ($this->app->isClient('administrator')) + { + $this->handleOneTimeLogin(); + $this->checkSetupRequired(); + $this->preserveDownloadKeys(); + } + } + + /** + * Event triggered after an extension's config is saved. + * + * Checks for maintenance action toggles (reset_hits, delete_versions). + * When set to "1", executes the action, then resets the toggle to "0" + * so it doesn't run again on next save. + * + * @param string $context The extension context (e.g. com_plugins.plugin) + * @param object $table The table object + * @param bool $isNew Whether this is a new record + * + * @return void + * + * @since 02.01.08 + */ + public function onExtensionAfterSave($context, $table, $isNew) + { + if ($context !== 'com_plugins.plugin') + { + return; + } + + // Only act on our own plugin + if ($table->element !== 'mokowaas' || $table->folder !== 'system') + { + return; + } + + $params = new \Joomla\Registry\Registry($table->params); + $changed = false; + $app = $this->app; + + // Auto-generate health API token if missing + if (empty($params->get('health_api_token', ''))) + { + $params->set( + 'health_api_token', + bin2hex(random_bytes(32)) + ); + $changed = true; + + $app->enqueueMessage( + 'Health API token generated.', + 'message' + ); + } + + // Auto-set primary domain on first save + if (empty($params->get('primary_domain', ''))) + { + $host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); + + if (!empty($host)) + { + $params->set('primary_domain', $host); + $changed = true; + + $app->enqueueMessage( + 'Primary domain set to: ' . $host, + 'message' + ); + } + } + + // Grafana auto-provisioning + $this->handleGrafanaProvisioning($params, $app); + + // Clear setup-required flag on save (new client setup complete) + $flagFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_setup_required.flag'; + + if (file_exists($flagFile)) + { + @unlink($flagFile); + $app->enqueueMessage('Client setup complete β€” setup flag cleared.', 'message'); + } + + if ($changed) + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' + . $db->quote($params->toString())) + ->where($db->quoteName('extension_id') . ' = ' + . (int) $table->extension_id) + ); + $db->execute(); + } + } + + /** + * Inject visual branding into the document head. + * + * Fires just before is compiled β€” injects favicon, logo CSS, + * admin color scheme, and custom CSS. + * + * @return void + * + * @since 02.01.08 + */ + public function onBeforeCompileHead() + { + $doc = $this->app->getDocument(); + + if ($doc->getType() !== 'html') + { + return; + } + + // Inject robots meta tag for alias domains (frontend only) + if ($this->app->isClient('site')) + { + $this->injectAliasRobots($doc); + } + + if (!$this->app->isClient('administrator')) + { + return; + } + + $this->redirectHelpMenu($doc); + } + + /** + * Redirect the admin Help menu link to the configured support URL. + * + * Joomla's Atum template hardcodes the Help link to help.joomla.org. + * This replaces it with the WaaS support URL via JS injection. + * + * @param \Joomla\CMS\Document\HtmlDocument $doc Document object + * + * @return void + * + * @since 02.10.00 + */ + protected function redirectHelpMenu($doc) + { + $supportUrl = 'https://mokoconsulting.tech/support'; + + $doc->addScriptDeclaration(" + document.addEventListener('DOMContentLoaded', function() { + var url = " . json_encode($supportUrl) . "; + document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) { + link.href = url; + link.target = '_blank'; + }); + document.querySelectorAll('a[href*=\"dashboard=help\"]').forEach(function(link) { + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + }); + }); + "); + } + + /** + * Prevent non-master users from disabling the plugin via save. + * + * @param string $context Extension context + * @param object $table Extension table row + * @param bool $isNew Whether this is a new record + * + * @return bool False to cancel save + * + * @since 02.03.04 + */ + public function onExtensionBeforeSave($context, $table, $isNew) + { + if ($context !== 'com_plugins.plugin') + { + return true; + } + + if ($table->element !== 'mokowaas' || $table->folder !== 'system') + { + return true; + } + + // Non-master users cannot disable the plugin + if (!$this->isMasterUser() && (int) $table->enabled === 0) + { + $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); + $table->enabled = 1; + } + + return true; + } + + /** + * Cascade enable/disable state across all MokoWaaS extensions. + * + * When the core system plugin (plg_system_mokowaas) is disabled, + * all feature plugins and the cpanel module are also disabled. + * When re-enabled, they are re-enabled too. + * + * @param string $context The extension context + * @param array $pks Extension IDs being changed + * @param int $value New state (1=enabled, 0=disabled) + * + * @return void + * + * @since 02.32.00 + */ + public function onExtensionChangeState($context, $pks, $value) + { + if (empty($pks)) + { + return; + } + + try + { + $db = Factory::getDbo(); + + // Check if the core MokoWaaS plugin is among the changed extensions + $query = $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $coreId = (int) $db->loadResult(); + + if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true)) + { + return; + } + + // Cascade to all MokoWaaS feature plugins + module + $mokoElements = [ + $db->quote('mokowaas_firewall'), + $db->quote('mokowaas_tenant'), + $db->quote('mokowaas_devtools'), + $db->quote('mokowaas_offline'), + $db->quote('mod_mokowaas_cpanel'), + ]; + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = ' . (int) $value) + ->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')'); + $db->setQuery($query); + $db->execute(); + $affected = $db->getAffectedRows(); + + // Also update module published state + if ($value == 0) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__modules')) + ->set($db->quoteName('published') . ' = 0') + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) + )->execute(); + } + else + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__modules')) + ->set($db->quoteName('published') . ' = 1') + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) + )->execute(); + } + + $state = $value ? 'enabled' : 'disabled'; + $this->app->enqueueMessage( + "MokoWaaS: {$state} {$affected} associated extensions.", + 'message' + ); + } + catch (\Throwable $e) + { + Log::add('MokoWaaS cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + // onPreprocessMenuItems β€” REMOVED, now in plg_system_mokowaas_tenant + // onUserBeforeSave β€” REMOVED, now in plg_system_mokowaas_firewall + + // ------------------------------------------------------------------ + // Diagnostics / Health Endpoint (called from onAfterInitialise) + // ------------------------------------------------------------------ + + /** + * Route MokoWaaS API requests. + * + * Validates the API token and dispatches to the appropriate handler. + * Endpoint: + * ?mokowaas=health β€” 16 diagnostic checks (GET) + * + * @param string $action The API action + * + * @return void + * + * @since 02.01.39 + */ + protected function handleMokoApi($action) + { + // Validate token for all endpoints + $expectedToken = $this->params->get('health_api_token', ''); + + if (empty($expectedToken)) + { + $this->sendHealthResponse(503, ['error' => 'No API token']); + + return; + } + + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] + ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] + ?? ''; + $providedToken = ''; + + if (stripos($authHeader, 'Bearer ') === 0) + { + $providedToken = trim(substr($authHeader, 7)); + } + else + { + $providedToken = $this->app->input->get('token', '', 'RAW'); + } + + if (!hash_equals($expectedToken, $providedToken)) + { + $this->sendHealthResponse(401, ['error' => 'Invalid token']); + + return; + } + + switch ($action) + { + case 'health': + $this->handleHealthAction(); + break; + default: + $this->sendHealthResponse(400, [ + 'error' => 'Unknown action', + 'action' => $action, + 'available' => ['health'], + ]); + break; + } + } + + /** + * Health check action β€” delegates to existing health check logic. + * + * @return void + * @since 02.01.39 + */ + protected function handleHealthAction() + { + // Token already validated by handleMokoApi() + // Collect diagnostics + $checks = $this->collectHealthChecks(); + + // Determine overall status and collect reasons + $overall = 'ok'; + $reasons = []; + + foreach ($checks as $name => $check) + { + $checkStatus = $check['status'] ?? 'ok'; + + if ($checkStatus === 'error') + { + $overall = 'error'; + $reasons[] = $name . ': ' . ($check['message'] ?? 'error'); + } + elseif ($checkStatus === 'degraded') + { + if ($overall !== 'error') + { + $overall = 'degraded'; + } + + // Build human-readable reason + if ($name === 'extensions' + && isset($check['pending_updates'])) + { + $reasons[] = $check['pending_updates'] + . ' extension update' + . ($check['pending_updates'] > 1 ? 's' : '') + . ' available'; + } + elseif ($name === 'filesystem' + && isset($check['free_disk_mb']) + && $check['free_disk_mb'] < 100) + { + $reasons[] = 'Low disk space: ' + . $check['free_disk_mb'] . ' MB free'; + } + elseif ($name === 'backup') + { + if (!empty($check['message'])) + { + $reasons[] = $check['message']; + } + elseif (isset($check['days_since']) + && $check['days_since'] > 7) + { + $reasons[] = 'Last backup ' + . $check['days_since'] . ' days ago'; + } + elseif (isset($check['last_status']) + && $check['last_status'] !== 'complete') + { + $reasons[] = 'Last backup status: ' + . $check['last_status']; + } + else + { + $reasons[] = 'Backup: degraded'; + } + } + elseif ($name === 'ssl' && isset($check['days_left'])) + { + $reasons[] = 'SSL expires in ' + . $check['days_left'] . ' days'; + } + elseif ($name === 'cron' && isset($check['failed_24h'])) + { + $reasons[] = $check['failed_24h'] + . ' scheduled task(s) failed'; + } + elseif ($name === 'config' && !empty($check['issues'])) + { + $reasons[] = implode(', ', $check['issues']); + } + else + { + $reasons[] = $name . ': degraded'; + } + } + } + + $payload = [ + 'status' => $overall, + 'reason' => implode('; ', $reasons) ?: null, + 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), + 'checks' => $checks, + 'meta' => $this->collectHealthMeta(), + ]; + + $this->sendHealthResponse( + $overall === 'error' ? 503 : 200, + $payload + ); + } + + /** + * Collect all health check results. + * + * @return array Associative array of check name => result + * + * @since 02.01.22 + */ + protected function collectHealthChecks() + { + $checks = [ + 'database' => $this->checkDatabase(), + 'filesystem' => $this->checkFilesystem(), + 'cache' => $this->checkCache(), + 'extensions' => $this->checkExtensions(), + 'backup' => $this->checkAkeebaBackup(), + 'security' => $this->checkAdminTools(), + 'ssl' => $this->checkSsl(), + 'cron' => $this->checkScheduledTasks(), + 'errors' => $this->checkErrorLog(), + 'db_size' => $this->checkDatabaseSize(), + 'content' => $this->checkContent(), + 'users' => $this->checkUserActivity(), + 'mail' => $this->checkMail(), + 'seo' => $this->checkSeo(), + 'template' => $this->checkTemplate(), + 'config' => $this->checkConfigDrift(), + ]; + + return $checks; + } + + /** + * Collect metadata about the instance. + * + * @return array + * + * @since 02.01.22 + */ + protected function collectHealthMeta() + { + $config = Factory::getConfig(); + + return [ + 'brand' => 'MokoWaaS', + 'plugin_version' => $this->getPluginVersion(), + 'joomla_version' => JVERSION, + 'php_version' => PHP_VERSION, + 'server_name' => $config->get('sitename', ''), + 'server_time' => gmdate('Y-m-d\TH:i:s\Z'), + ]; + } + + /** + * Check database connectivity and query latency. + * + * @return array Check result with status and metrics + * + * @since 02.01.22 + */ + protected function checkDatabase() + { + try + { + $db = Factory::getDbo(); + $start = microtime(true); + + $db->setQuery('SELECT 1'); + $db->execute(); + + $latencyMs = round((microtime(true) - $start) * 1000, 2); + + // Count users as a real-table sanity check + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__users')) + ); + $userCount = (int) $db->loadResult(); + + return [ + 'status' => 'ok', + 'latency_ms' => $latencyMs, + 'driver' => $db->getName(), + 'users' => $userCount, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'error', + 'message' => 'Database unreachable', + ]; + } + } + + /** + * Check filesystem health (writable dirs, disk space). + * + * @return array Check result with status and metrics + * + * @since 02.01.22 + */ + protected function checkFilesystem() + { + $tmpWritable = is_writable(JPATH_ROOT . '/tmp'); + $logWritable = is_writable(JPATH_ROOT . '/administrator/logs'); + $cacheWritable = is_writable(JPATH_ROOT . '/cache'); + + $freeBytes = @disk_free_space(JPATH_ROOT); + $freeMb = $freeBytes !== false + ? round($freeBytes / 1048576) + : null; + + $allWritable = $tmpWritable && $logWritable && $cacheWritable; + + $status = 'ok'; + + if (!$allWritable) + { + $status = 'error'; + } + elseif ($freeMb !== null && $freeMb < 100) + { + $status = 'degraded'; + } + + // Total disk and site size + $totalBytes = @disk_total_space(JPATH_ROOT); + $totalMb = $totalBytes !== false + ? round($totalBytes / 1048576) + : null; + + // Site directory size (quick estimate via common dirs) + $siteMb = null; + + try + { + $siteSize = 0; + + foreach (['images', 'media', 'tmp', 'cache', + 'administrator/logs', 'administrator/cache'] as $dir) + { + $path = JPATH_ROOT . '/' . $dir; + + if (is_dir($path)) + { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $path, + \FilesystemIterator::SKIP_DOTS + ) + ); + + foreach ($iter as $file) + { + $siteSize += $file->getSize(); + } + } + } + + $siteMb = round($siteSize / 1048576); + } + catch (\Exception $e) + { + // Ignore β€” siteMb stays null + } + + return [ + 'status' => $status, + 'tmp_writable' => $tmpWritable, + 'log_writable' => $logWritable, + 'cache_writable' => $cacheWritable, + 'free_disk_mb' => $freeMb, + 'total_disk_mb' => $totalMb, + 'site_size_mb' => $siteMb, + ]; + } + + /** + * Check Joomla cache status. + * + * @return array Check result + * + * @since 02.01.22 + */ + protected function checkCache() + { + $config = Factory::getConfig(); + $enabled = (bool) $config->get('caching', 0); + $handler = $config->get('cache_handler', 'file'); + + return [ + 'status' => 'ok', + 'enabled' => $enabled, + 'handler' => $handler, + ]; + } + + /** + * Check extension counts and update status. + * + * @return array Check result with extension metrics + * + * @since 02.01.22 + */ + protected function checkExtensions() + { + try + { + $db = Factory::getDbo(); + + // Count enabled extensions by type + $query = $db->getQuery(true) + ->select([ + $db->quoteName('type'), + 'COUNT(*) AS ' . $db->quoteName('total'), + ]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('enabled') . ' = 1') + ->group($db->quoteName('type')); + + $db->setQuery($query); + $rows = $db->loadObjectList('type'); + + $counts = []; + + foreach ($rows as $type => $row) + { + $counts[$type] = (int) $row->total; + } + + // Check for available updates + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__updates')) + ->where($db->quoteName('extension_id') . ' != 0') + ); + $pendingUpdates = (int) $db->loadResult(); + + $status = $pendingUpdates > 0 ? 'degraded' : 'ok'; + + return [ + 'status' => $status, + 'counts' => $counts, + 'pending_updates' => $pendingUpdates, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'error', + 'message' => 'Could not query extensions', + ]; + } + } + + /** + * Check Akeeba Backup status β€” last backup date, status, and profile. + * + * Queries the #__ak_stats table (Akeeba Backup) for the most recent + * backup record. Returns 'not_installed' if the table doesn't exist. + * + * @return array Check result with backup info + * + * @since 02.01.39 + */ + protected function checkAkeebaBackup() + { + try + { + $db = Factory::getDbo(); + + // Check if Akeeba Backup is installed + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + $akTable = $prefix . 'ak_stats'; + + if (!in_array($akTable, $tables)) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + + // Get the most recent backup + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('description'), + $db->quoteName('status'), + $db->quoteName('backupstart'), + $db->quoteName('backupend'), + $db->quoteName('profile_id'), + $db->quoteName('total_size'), + ]) + ->from($db->quoteName('#__ak_stats')) + ->order($db->quoteName('id') . ' DESC'); + + $db->setQuery($query, 0, 1); + $latest = $db->loadObject(); + + if (!$latest) + { + return [ + 'status' => 'degraded', + 'installed' => true, + 'message' => 'No backups found', + ]; + } + + // Count total backups and recent (last 7 days) + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__ak_stats')) + ); + $totalBackups = (int) $db->loadResult(); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__ak_stats')) + ->where($db->quoteName('backupstart') + . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') + ); + $recentBackups = (int) $db->loadResult(); + + // Check if last backup is older than 7 days + $lastDate = $latest->backupstart; + $daysSince = (int) ((time() - strtotime($lastDate)) / 86400); + $backupSize = $latest->total_size + ? round($latest->total_size / 1048576) + : null; + + $status = 'ok'; + + if ($latest->status !== 'complete') + { + $status = 'degraded'; + } + elseif ($daysSince > 7) + { + $status = 'degraded'; + } + + return [ + 'status' => $status, + 'installed' => true, + 'last_backup' => $lastDate, + 'last_status' => $latest->status, + 'last_size_mb' => $backupSize, + 'days_since' => $daysSince, + 'profile_id' => (int) $latest->profile_id, + 'total_backups' => $totalBackups, + 'recent_7d' => $recentBackups, + 'description' => $latest->description, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + } + + /** + * Check Admin Tools status β€” WAF status, security exceptions. + * + * Queries Admin Tools tables for firewall status and recent blocks. + * Returns 'not_installed' if tables don't exist. + * + * @return array Check result with security info + * + * @since 02.01.39 + */ + protected function checkAdminTools() + { + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + // Check if Admin Tools is installed + $atTable = $prefix . 'admintools_log'; + + if (!in_array($atTable, $tables)) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + + // Count blocked requests in last 24h + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__admintools_log')) + ->where($db->quoteName('logdate') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $blocked24h = (int) $db->loadResult(); + + // Count blocked in last 7 days + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__admintools_log')) + ->where($db->quoteName('logdate') + . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') + ); + $blocked7d = (int) $db->loadResult(); + + // Check WAF config if available + $wafEnabled = null; + $wafTable = $prefix . 'admintools_wafconfig'; + + if (in_array($wafTable, $tables)) + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('value')) + ->from($db->quoteName('#__admintools_wafconfig')) + ->where($db->quoteName('key') . ' = ' + . $db->quote('ipworkarounds')) + ); + $wafEnabled = $db->loadResult() !== null; + } + + return [ + 'status' => 'ok', + 'installed' => true, + 'blocked_24h' => $blocked24h, + 'blocked_7d' => $blocked7d, + 'waf_active' => $wafEnabled, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + } + + /** + * Check SSL certificate expiry. + * + * @return array + * @since 02.01.39 + */ + protected function checkSsl() + { + try + { + $siteUrl = Uri::root(); + $host = parse_url($siteUrl, PHP_URL_HOST); + + if (empty($host) || parse_url($siteUrl, PHP_URL_SCHEME) !== 'https') + { + return ['status' => 'ok', 'https' => false]; + } + + $ctx = stream_context_create([ + 'ssl' => ['capture_peer_cert' => true, 'verify_peer' => false], + ]); + $stream = @stream_socket_client( + "ssl://{$host}:443", $errno, $errstr, 10, + STREAM_CLIENT_CONNECT, $ctx + ); + + if (!$stream) + { + return ['status' => 'degraded', 'https' => true, 'message' => 'Cannot connect']; + } + + $params = stream_context_get_params($stream); + $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); + fclose($stream); + + $expiresTs = $cert['validTo_time_t'] ?? 0; + $daysLeft = (int) (($expiresTs - time()) / 86400); + $issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown'; + $status = $daysLeft < 7 ? 'error' : ($daysLeft < 30 ? 'degraded' : 'ok'); + + return [ + 'status' => $status, + 'https' => true, + 'expires' => gmdate('Y-m-d', $expiresTs), + 'days_left' => $daysLeft, + 'issuer' => $issuer, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'https' => false]; + } + } + + /** + * Check Joomla scheduled tasks (Joomla 4.1+). + * + * @return array + * @since 02.01.39 + */ + protected function checkScheduledTasks() + { + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!in_array($prefix . 'scheduler_tasks', $tables)) + { + return ['status' => 'ok', 'available' => false]; + } + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ); + $enabled = (int) $db->loadResult(); + + $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('last_execution'), + $db->quoteName('last_exit_code'), + $db->quoteName('next_execution'), + ]) + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ->order($db->quoteName('last_execution') . ' DESC') + ); + $db->setQuery($db->getQuery(true), 0, 5); + // Re-run the query + $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('last_execution'), + $db->quoteName('last_exit_code'), + $db->quoteName('next_execution'), + ]) + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ->order($db->quoteName('last_execution') . ' DESC'), + 0, 1 + ); + $last = $db->loadObject(); + + // Count failed in last 24h + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('last_exit_code') . ' != 0') + ->where($db->quoteName('last_execution') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $failed24h = (int) $db->loadResult(); + + $status = $failed24h > 0 ? 'degraded' : 'ok'; + + return [ + 'status' => $status, + 'available' => true, + 'enabled_tasks' => $enabled, + 'failed_24h' => $failed24h, + 'last_run' => $last->last_execution ?? null, + 'last_exit_code' => $last ? (int) $last->last_exit_code : null, + 'last_task' => $last->title ?? null, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'available' => false]; + } + } + + /** + * Check PHP error log for recent errors. + * + * @return array + * @since 02.01.39 + */ + protected function checkErrorLog() + { + $logFile = JPATH_ROOT . '/administrator/logs/error.php'; + $altLog = ini_get('error_log'); + + $file = null; + + if (file_exists($logFile) && is_readable($logFile)) + { + $file = $logFile; + } + elseif ($altLog && file_exists($altLog) && is_readable($altLog)) + { + $file = $altLog; + } + + if (!$file) + { + return [ + 'status' => 'ok', + 'log_available' => false, + ]; + } + + $size = filesize($file); + $sizeMb = round($size / 1048576, 1); + + // Count recent lines (tail last 50 lines, count errors) + $lines = file_exists($file) ? @file($file) : []; + $recent = array_slice($lines, -50); + $errors24h = 0; + $lastError = null; + $yesterday = date('Y-m-d', strtotime('-1 day')); + + foreach ($recent as $line) + { + if (stripos($line, 'error') !== false + || stripos($line, 'fatal') !== false) + { + $errors24h++; + $lastError = trim(substr($line, 0, 200)); + } + } + + return [ + 'status' => 'ok', + 'log_available' => true, + 'log_size_mb' => $sizeMb, + 'recent_errors' => $errors24h, + 'last_error' => $lastError, + ]; + } + + /** + * Check database size and largest tables. + * + * @return array + * @since 02.01.39 + */ + protected function checkDatabaseSize() + { + try + { + $db = Factory::getDbo(); + $config = Factory::getConfig(); + $dbName = $config->get('db'); + + $db->setQuery( + "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) AS size_mb " + . "FROM information_schema.tables WHERE table_schema = " + . $db->quote($dbName) + ); + $totalMb = (float) $db->loadResult(); + + // Largest tables + $db->setQuery( + "SELECT table_name, " + . "ROUND((data_length + index_length) / 1048576, 1) AS size_mb " + . "FROM information_schema.tables " + . "WHERE table_schema = " . $db->quote($dbName) + . " ORDER BY (data_length + index_length) DESC LIMIT 5" + ); + $largest = []; + + foreach ($db->loadObjectList() as $t) + { + $largest[$t->table_name] = (float) $t->size_mb; + } + + // Table count + $db->setQuery( + "SELECT COUNT(*) FROM information_schema.tables " + . "WHERE table_schema = " . $db->quote($dbName) + ); + $tableCount = (int) $db->loadResult(); + + return [ + 'status' => 'ok', + 'total_mb' => $totalMb, + 'table_count' => $tableCount, + 'largest' => $largest, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'total_mb' => null]; + } + } + + /** + * Check content statistics. + * + * @return array + * @since 02.01.39 + */ + protected function checkContent() + { + try + { + $db = Factory::getDbo(); + + $counts = []; + + foreach ([ + 'articles' => '#__content', + 'categories' => '#__categories', + 'menu_items' => '#__menu', + 'modules' => '#__modules', + 'media' => '#__media_files', + ] as $label => $table) + { + try + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName($table)) + ); + $counts[$label] = (int) $db->loadResult(); + } + catch (\Exception $e) + { + // Table might not exist + } + } + + return [ + 'status' => 'ok', + 'counts' => $counts, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'counts' => []]; + } + } + + /** + * Check user activity β€” last login, active sessions, failed logins. + * + * @return array + * @since 02.01.39 + */ + protected function checkUserActivity() + { + try + { + $db = Factory::getDbo(); + + // Total users + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__users')) + ); + $totalUsers = (int) $db->loadResult(); + + // Last login + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('lastvisitDate')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('lastvisitDate') + . ' IS NOT NULL') + ->order($db->quoteName('lastvisitDate') . ' DESC'), + 0, 1 + ); + $lastLogin = $db->loadResult(); + + // Active sessions + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__session')) + ->where($db->quoteName('guest') . ' = 0') + ); + $activeSessions = (int) $db->loadResult(); + + // Failed logins (from action logs if available) + $failedLogins = 0; + + try + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__action_logs')) + ->where($db->quoteName('message_language_key') + . ' LIKE ' . $db->quote('%LOGIN_FAILED%')) + ->where($db->quoteName('log_date') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $failedLogins = (int) $db->loadResult(); + } + catch (\Exception $e) + { + // Action logs might not track this + } + + return [ + 'status' => 'ok', + 'total_users' => $totalUsers, + 'last_login' => $lastLogin, + 'active_sessions' => $activeSessions, + 'failed_24h' => $failedLogins, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'total_users' => null]; + } + } + + /** + * Check mail system status. + * + * @return array + * @since 02.01.39 + */ + protected function checkMail() + { + try + { + $config = Factory::getConfig(); + $mailer = $config->get('mailer', 'mail'); + $from = $config->get('mailfrom', ''); + $smtpHost = $config->get('smtphost', ''); + + // Check mail queue if available + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + $queueCount = 0; + + if (in_array($prefix . 'mail_queue', $tables)) + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mail_queue')) + ); + $queueCount = (int) $db->loadResult(); + } + + return [ + 'status' => 'ok', + 'mailer' => $mailer, + 'from' => $from, + 'smtp_host' => $mailer === 'smtp' ? $smtpHost : null, + 'queue' => $queueCount, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'mailer' => null]; + } + } + + /** + * Check basic SEO health indicators. + * + * @return array + * @since 02.01.39 + */ + protected function checkSeo() + { + $robotsTxt = file_exists(JPATH_ROOT . '/robots.txt'); + $htaccess = file_exists(JPATH_ROOT . '/.htaccess'); + + // Check for sitemap + $sitemapXml = file_exists(JPATH_ROOT . '/sitemap.xml'); + $sitemapIdx = file_exists(JPATH_ROOT . '/sitemap_index.xml'); + + $config = Factory::getConfig(); + $sef = (bool) $config->get('sef', 0); + + return [ + 'status' => 'ok', + 'robots_txt' => $robotsTxt, + 'htaccess' => $htaccess, + 'sitemap' => $sitemapXml || $sitemapIdx, + 'sef_enabled' => $sef, + ]; + } + + /** + * Check active template info. + * + * @return array + * @since 02.01.39 + */ + protected function checkTemplate() + { + try + { + $db = Factory::getDbo(); + + // Site template + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('template')) + ->from($db->quoteName('#__template_styles')) + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('home') . ' = 1') + ); + $siteTemplate = $db->loadResult() ?: 'unknown'; + + // Admin template + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('template')) + ->from($db->quoteName('#__template_styles')) + ->where($db->quoteName('client_id') . ' = 1') + ->where($db->quoteName('home') . ' = 1') + ); + $adminTemplate = $db->loadResult() ?: 'unknown'; + + // Count template overrides + $overrideCount = 0; + $overridePath = JPATH_ROOT . '/templates/' . $siteTemplate . '/html'; + + if (is_dir($overridePath)) + { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $overridePath, + \FilesystemIterator::SKIP_DOTS + ) + ); + + foreach ($iter as $file) + { + if ($file->isFile()) + { + $overrideCount++; + } + } + } + + return [ + 'status' => 'ok', + 'site_template' => $siteTemplate, + 'admin_template' => $adminTemplate, + 'override_count' => $overrideCount, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'site_template' => null]; + } + } + + /** + * Check configuration for common misconfigurations. + * + * @return array + * @since 02.01.39 + */ + protected function checkConfigDrift() + { + $config = Factory::getConfig(); + + $debug = (bool) $config->get('debug', 0); + $errorReport = $config->get('error_reporting', 'default'); + $gzip = (bool) $config->get('gzip', 0); + $sef = (bool) $config->get('sef', 0); + $sefRewrite = (bool) $config->get('sef_rewrite', 0); + $forceSSL = (int) $config->get('force_ssl', 0); + $caching = (bool) $config->get('caching', 0); + $lifetime = (int) $config->get('lifetime', 15); + $tmpPath = $config->get('tmp_path', ''); + $logPath = $config->get('log_path', ''); + + // Flag potential issues + $issues = []; + + if ($debug) + { + $issues[] = 'Debug mode is ON'; + } + + if ($errorReport === 'maximum' + || $errorReport === 'development') + { + $issues[] = 'Error reporting: ' . $errorReport; + } + + if ($forceSSL === 0) + { + $issues[] = 'Force SSL is OFF'; + } + + $status = empty($issues) ? 'ok' : 'degraded'; + + return [ + 'status' => $status, + 'debug' => $debug, + 'error_report' => $errorReport, + 'gzip' => $gzip, + 'sef' => $sef, + 'sef_rewrite' => $sefRewrite, + 'force_ssl' => $forceSSL, + 'caching' => $caching, + 'lifetime' => $lifetime, + 'issues' => $issues ?: null, + ]; + } + + /** + * Send a JSON health response and terminate execution. + * + * @param int $httpCode HTTP status code + * @param array $payload Data to encode as JSON + * + * @return void + * + * @since 02.01.22 + */ + protected function sendHealthResponse($httpCode, array $payload) + { + http_response_code($httpCode); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('X-MokoWaaS-Health: 1'); + echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $this->app->close(); + } + + // ------------------------------------------------------------------ + // Site Alias handling + // ------------------------------------------------------------------ + + /** + * Get the alias configuration for the current request domain, if any. + * + * @return object|null Alias entry object or null if not an alias domain + * + * @since 02.01.43 + */ + /** + * Get the primary domain from Joomla config or by exclusion from aliases. + * + * @return string Primary domain hostname + * + * @since 02.03.05 + */ + protected function getPrimaryHost(): string + { + $primaryDomain = $this->params->get('primary_domain', ''); + + if (!empty($primaryDomain)) + { + return trim($primaryDomain); + } + + // Fallback: Joomla's $live_site + $liveSite = Factory::getConfig()->get('live_site', ''); + + if (!empty($liveSite)) + { + $host = parse_url($liveSite, PHP_URL_HOST); + + if ($host) + { + return $host; + } + } + + return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); + } + + /** + * Get the dev alias domain (dev.{primary_domain}). + * + * @return string + * + * @since 02.31.00 + */ + protected function getDevAliasDomain(): string + { + $primary = $this->getPrimaryHost(); + + return !empty($primary) ? 'dev.' . $primary : ''; + } + + /** + * Check if the current request is on the dev alias domain. + * + * @return bool + * + * @since 02.31.00 + */ + protected function isDevAlias(): bool + { + $currentHost = $_SERVER['HTTP_HOST'] ?? ''; + $devDomain = $this->getDevAliasDomain(); + + return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0; + } + + protected function getCurrentAlias() + { + $currentHost = $_SERVER['HTTP_HOST'] ?? ''; + + if (empty($currentHost)) + { + return null; + } + + // The only alias is dev.{primary_domain} + $devDomain = $this->getDevAliasDomain(); + + if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0) + { + return null; + } + + // Return a synthetic alias object for the dev domain + return (object) [ + 'domain' => $devDomain, + 'offline' => '0', + 'redirect_backend' => '0', + 'robots' => 'noindex, nofollow', + ]; + } + + /** + * Legacy compatibility β€” old getCurrentAlias read from site_aliases param. + * Now only returns the hardcoded dev.* alias. + */ + private function getCurrentAliasLegacy() + { + $aliases = $this->params->get('site_aliases', ''); + + if (empty($aliases)) + { + return null; + } + + // Subform returns JSON string, array, or stdClass + if (is_string($aliases)) + { + $aliases = json_decode($aliases); + } + + // Convert object to array (Joomla subform stores as {"key0":{...},"key1":{...}}) + if (is_object($aliases)) + { + $aliases = (array) $aliases; + } + + if (!is_array($aliases) || empty($aliases)) + { + return null; + } + + // Look up the current host in the aliases list β€” if found, it's an alias + foreach ($aliases as $alias) + { + $alias = (object) $alias; + + if (isset($alias->domain) && strcasecmp(rtrim(trim($alias->domain), '/'), $currentHost) === 0) + { + return $alias; + } + } + + return null; + } + + /** + * Handle site alias logic: offline page and backend redirect. + * + * Runs in onAfterInitialise so that Joomla's offline check in + * SiteApplication::doExecute() sees the updated config value. + * + * @return void + * + * @since 02.01.43 + */ + protected function handleSiteAlias() + { + // The dev alias (dev.{primary_domain}) always bypasses offline mode + if ($this->isDevAlias()) + { + $this->app->getConfig()->set('offline', 0); + + return; + } + } + + /** + * Inject robots meta tag for alias domains. + * + * @param \Joomla\CMS\Document\HtmlDocument $doc Document object + * + * @return void + * + * @since 02.01.43 + */ + protected function injectAliasRobots($doc) + { + // Always noindex/nofollow on the dev alias domain + if ($this->isDevAlias()) + { + $doc->setMetaData('robots', 'noindex, nofollow'); + } + + // Inject canonical URL pointing to the primary domain + $primaryHost = $this->getPrimaryHost(); + $currentUri = Uri::getInstance(); + $canonical = $currentUri->getScheme() . '://' . $primaryHost . $currentUri->toString(['path', 'query']); + $doc->addHeadLink($canonical, 'canonical'); + } + + // ------------------------------------------------------------------ + // Heartbeat (called from onExtensionAfterSave) + // ------------------------------------------------------------------ + // License key check (called from onAfterRoute) + // ------------------------------------------------------------------ + + // ------------------------------------------------------------------ + + /** + * Send heartbeat to the MokoWaaS monitoring receiver. + * + * Registers this site's primary domain with the Grafana provisioning system. + * The receiver writes a datasource YAML file and restarts Grafana. + * Alias domains are not registered to avoid duplicate datasource UIDs. + * + * @param \Joomla\Registry\Registry $params Plugin params + * @param \Joomla\CMS\Application\CMSApplication $app Application + * + * @return void + * + * @since 02.01.36 + */ + protected function handleGrafanaProvisioning($params, $app) + { + $healthToken = $params->get('health_api_token', ''); + + if (empty($healthToken)) + { + return; + } + + $siteUrl = rtrim(Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + + // Register primary domain + $this->sendHeartbeat($siteUrl, $siteName, $healthToken, $app); + + // Register alias domains (subform format) + $aliases = $params->get('site_aliases', ''); + + if (!empty($aliases)) + { + if (is_string($aliases)) + { + $aliases = json_decode($aliases); + } + + if (is_object($aliases)) + { + $aliases = (array) $aliases; + } + + if (is_array($aliases)) + { + foreach ($aliases as $alias) + { + $alias = (object) $alias; + + if (!empty($alias->domain)) + { + $domain = rtrim(trim($alias->domain), '/'); + $aliasUrl = 'https://' . preg_replace('#^https?://#i', '', $domain); + $this->sendHeartbeat($aliasUrl, $siteName, $healthToken, $app); + } + } + } + } + } + + /** + * Send a single heartbeat registration to the receiver. + * + * @param string $siteUrl Site URL to register + * @param string $siteName Display name for Grafana + * @param string $healthToken Health API bearer token + * @param object $app Application for messages + * + * @return void + * + * @since 02.01.39 + */ + protected function sendHeartbeat($siteUrl, $siteName, $healthToken, $app) + { + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $healthToken, + 'action' => 'register', + ], JSON_UNESCAPED_SLASHES); + + $ch = curl_init(self::HEARTBEAT_URL . '/register'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + 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); + $error = curl_error($ch); + curl_close($ch); + + $body = json_decode($response, true); + + if ($error) + { + $app->enqueueMessage('Grafana heartbeat failed (' . $siteUrl . '): ' . $error, 'warning'); + Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); + } + elseif ($code === 200) + { + $status = $body['status'] ?? 'ok'; + $app->enqueueMessage( + 'Grafana heartbeat: ' . $siteUrl . ' ' . $status . ' (' . ($body['ds_uid'] ?? '') . ')', + 'message' + ); + } + else + { + $msg = sprintf('Grafana heartbeat failed (%s): HTTP %d β€” %s', + $siteUrl, $code, $body['error'] ?? $body['message'] ?? 'Unknown'); + $app->enqueueMessage($msg, 'warning'); + Log::add($msg, Log::WARNING, 'mokowaas'); + } + } + + // HTTPS / Session / License (called from onAfterInitialise) + // ------------------------------------------------------------------ + + /** + * Redirect HTTP requests to HTTPS. + * + * @return void + * + * @since 02.01.08 + */ + + + + // ------------------------------------------------------------------ + // Tenant Restrictions (called from onAfterRoute) + // ------------------------------------------------------------------ + + /** + * Check whether the current user is the master WaaS user. + * + * @return boolean + * + * @since 02.01.08 + */ + protected function isMasterUser() + { + $user = $this->app->getIdentity(); + + if (!$user || $user->guest) + { + return false; + } + + return \in_array($user->username, $this->getMasterUsernames(), true); + } + + /** + * Decode obfuscated master usernames. + * + * @return array + * + * @since 02.29.01 + */ + private function getMasterUsernames(): array + { + if ($this->masterNames !== null) + { + return $this->masterNames; + } + + $this->masterNames = []; + + foreach (self::MASTER_KEYS as $encoded) + { + $raw = base64_decode($encoded); + $decoded = ''; + + for ($i = 0, $len = \strlen($raw); $i < $len; $i++) + { + $decoded .= \chr(\ord($raw[$i]) ^ self::MK); + } + + $this->masterNames[] = $decoded; + } + + return $this->masterNames; + } + + // ------------------------------------------------------------------ + // Setup Required Check + // ------------------------------------------------------------------ + + /** + * Check if the site has been provisioned for a new client and needs + * fresh setup information (company name, contact details). + * + * Shows a persistent admin banner until the setup flag is cleared + * by saving the core plugin settings. + * + * @return void + * + * @since 02.35.00 + */ + protected function checkSetupRequired(): void + { + $flagFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_setup_required.flag'; + + if (!file_exists($flagFile)) + { + return; + } + + $this->app->enqueueMessage( + 'New client setup required. This site has been provisioned for a new client. ' + . 'Please update the site name, contact details, and save the MokoWaaS plugin settings to complete setup. ' + . 'Open Settings', + 'warning' + ); + } + + /** + * Get this plugin's extension_id. + */ + private function getPluginExtensionId(): int + { + static $id = null; + + if ($id !== null) + { + return $id; + } + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $id = (int) $db->loadResult(); + } + catch (\Throwable $e) + { + $id = 0; + } + + return $id; + } + + // ------------------------------------------------------------------ + // One-Time Remote Login + // ------------------------------------------------------------------ + + /** + * Handle one-time login tokens from MokoWaaSBase remote login. + * + * Checks for ?mokowaas_otl=TOKEN in the admin URL, validates the + * token against the stored OTL file, auto-logs in the master user, + * and redirects to the admin dashboard. + * + * @return void + * + * @since 02.35.00 + */ + protected function handleOneTimeLogin(): void + { + $otlToken = $this->app->input->get('mokowaas_otl', '', 'RAW'); + + if (empty($otlToken)) + { + return; + } + + $otlFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_otl_' . md5($otlToken) . '.json'; + + if (!file_exists($otlFile)) + { + $this->app->enqueueMessage('Invalid or expired login token.', 'error'); + $this->app->redirect('index.php'); + + return; + } + + $data = json_decode(file_get_contents($otlFile), true); + + // Always delete the file immediately (one-time use) + @unlink($otlFile); + + if (!$data || !hash_equals($data['token'] ?? '', $otlToken)) + { + $this->app->enqueueMessage('Invalid login token.', 'error'); + $this->app->redirect('index.php'); + + return; + } + + if (time() > ($data['expires'] ?? 0)) + { + $this->app->enqueueMessage('Login token has expired.', 'error'); + $this->app->redirect('index.php'); + + return; + } + + $userId = (int) ($data['user_id'] ?? 0); + + if (!$userId) + { + $this->app->enqueueMessage('Invalid user in login token.', 'error'); + $this->app->redirect('index.php'); + + return; + } + + // Auto-login the user + $user = Factory::getUser($userId); + + if (!$user || $user->block) + { + $this->app->enqueueMessage('User not found or blocked.', 'error'); + $this->app->redirect('index.php'); + + return; + } + + // Perform login + $this->app->login([ + 'username' => $user->username, + ], ['action' => 'core.login.admin', 'autoregister' => false, 'skip_auth' => true]); + + Log::add( + sprintf('Remote login by %s from %s (origin: %s)', + $user->username, + $_SERVER['REMOTE_ADDR'] ?? '', + $data['origin'] ?? 'unknown' + ), + Log::INFO, + 'mokowaas' + ); + + $this->app->redirect('index.php'); + } + + // ------------------------------------------------------------------ + // Download Key Preservation + // ------------------------------------------------------------------ + + /** + * Preserve download keys across Joomla extension updates. + * + * Joomla's installer can wipe the extra_query column (which holds + * download keys / dlid) when rebuilding or reinstalling update sites. + * This method keeps a backup of all non-empty extra_query values and + * restores any that get cleared. + * + * @return void + * + * @since 02.34.12 + */ + protected function preserveDownloadKeys(): void + { + try + { + $db = Factory::getDbo(); + + // Load current extra_query values for all update sites + $query = $db->getQuery(true) + ->select([ + $db->quoteName('update_site_id'), + $db->quoteName('extra_query'), + $db->quoteName('location'), + ]) + ->from($db->quoteName('#__update_sites')); + $db->setQuery($query); + $sites = $db->loadObjectList('update_site_id') ?: []; + + $backupFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_dlkeys.json'; + $backup = []; + + if (file_exists($backupFile)) + { + $backup = json_decode(file_get_contents($backupFile), true) ?: []; + } + + $restored = 0; + $updated = false; + + foreach ($sites as $id => $site) + { + $currentKey = trim((string) $site->extra_query); + $backupKey = $backup[$id] ?? ''; + + if ($currentKey !== '') + { + // Site has a key β€” update backup if changed + if ($currentKey !== $backupKey) + { + $backup[$id] = $currentKey; + $updated = true; + } + } + elseif ($backupKey !== '') + { + // Key was wiped β€” restore from backup + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote($backupKey)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $id) + )->execute(); + + $restored++; + } + } + + // Clean up backup entries for update sites that no longer exist + $currentIds = array_keys($sites); + + foreach (array_keys($backup) as $backupId) + { + if (!isset($sites[$backupId])) + { + unset($backup[$backupId]); + $updated = true; + } + } + + if ($updated || $restored > 0) + { + file_put_contents($backupFile, json_encode($backup, JSON_PRETTY_PRINT)); + } + + if ($restored > 0) + { + Log::add( + sprintf('MokoWaaS: restored %d download key(s) that were cleared by Joomla.', $restored), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + // Non-critical β€” don't break the site over key backup + } + } +} diff --git a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php b/source/packages/plg_system_mokowaas/Field/CopyableTokenField.php similarity index 79% rename from src/packages/plg_system_mokowaas/Field/CopyableTokenField.php rename to source/packages/plg_system_mokowaas/Field/CopyableTokenField.php index 99d551bb..d7e1ac4b 100644 --- a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php +++ b/source/packages/plg_system_mokowaas/Field/CopyableTokenField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.34.16 * PATH: /src/Field/CopyableTokenField.php * BRIEF: Read-only token field with a copy-to-clipboard button */ @@ -39,8 +39,11 @@ class CopyableTokenField extends FormField return '
    Token will be generated automatically on first save.
    '; } + // Derive a human-readable support PIN from the token + $pin = strtoupper(substr($this->value, 0, 4) . '-' . substr($this->value, 4, 4)); + return << +
    +
    + MOKO-{$pin} + Support verification PIN β€” ask your provider for this code to verify your identity. +
    HTML; } } diff --git a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php b/source/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php similarity index 97% rename from src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php rename to source/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php index 0cd42177..3e533f84 100644 --- a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php +++ b/source/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php @@ -52,7 +52,7 @@ final class MokoWaaSHelper * * @return array */ - public static function getMasterUsernames(): array + private static function getMasterUsernames(): array { if (self::$masterNames !== null) { diff --git a/src/packages/plg_system_mokowaas/administrator/language/en-GB/index.html b/source/packages/plg_system_mokowaas/administrator/language/en-GB/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/administrator/language/en-GB/index.html rename to source/packages/plg_system_mokowaas/administrator/language/en-GB/index.html diff --git a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini b/source/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini similarity index 77% rename from src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini rename to source/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini index 7777eca0..14f672b5 100644 --- a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini +++ b/source/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini @@ -15,5 +15,5 @@ ; Variables: (none) ; ----------------------------------------------------------------------------- -PLG_SYSTEM_MOKOWAAS="System - MokoWaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, master user management, event routing, and admin customizations." diff --git a/src/packages/plg_system_mokowaas/administrator/language/en-US/index.html b/source/packages/plg_system_mokowaas/administrator/language/en-US/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/administrator/language/en-US/index.html rename to source/packages/plg_system_mokowaas/administrator/language/en-US/index.html diff --git a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini b/source/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini similarity index 77% rename from src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini rename to source/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini index 52c50199..802ce253 100644 --- a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini +++ b/source/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini @@ -15,5 +15,5 @@ ; Variables: (none) ; ----------------------------------------------------------------------------- -PLG_SYSTEM_MOKOWAAS="System - MokoWaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, master user management, event routing, and admin customizations." diff --git a/source/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini b/source/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini new file mode 100644 index 00000000..83563e9f --- /dev/null +++ b/source/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini @@ -0,0 +1,118 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language Overrides +; Ingroup: MokoWaaS +; Version: 02.01.08 +; File: en-GB.override.ini +; Path: administrator/language/overrides/en-GB.override.ini +; Brief: Admin language overrides β€” values are hardcoded. +; ----------------------------------------------------------------------------- + +; ===== Footer & template branding ===== +TPL_ATUM_POWERED_BY="Powered by MokoWaaS" +MOD_FOOTER_LINE2="Powered by MokoWaaS" + +; ===== Control panel greetings ===== +COM_CPANEL_WELCOME_TITLE="Welcome to MokoWaaS!" +COM_CPANEL_MSG_WELCOME="Welcome to MokoWaaS!" + +; ===== Help/Docs phrasing ===== +COM_ADMIN_HELP_SITE="MokoWaaS Help" +COM_ADMIN_HELPSITE_FIELD_LABEL="MokoWaaS Help" + +; ===== Generic replacements ===== +JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="MokoWaaS Defaults" +COM_INSTALLER_TYPE_JOOMLA="MokoWaaS Package" +LIB_JOOMLA="MokoWaaS Library" + +; ===== System messages ===== +JERROR_JOOMLA="MokoWaaS Error" +JFIELD_JOOMLA_LABEL="MokoWaaS Field" + +; ===== AdminLogin Support ===== +MOD_LOGINSUPPORT_FORUM="Moko Consulting Support" +MOD_LOGINSUPPORT_DOCUMENTATION="MokoWaaS Documentation" +MOD_LOGINSUPPORT_NEWS="Moko Consulting News" +MOD_LOGINSUPPORT_HEADLINE="Need help? Visit Moko Consulting:" +MOD_LOGINSUPPORT_XML_DESCRIPTION="This module displays useful links to Moko Consulting support on the login screen." +TPL_ATUM_BACKEND_LOGIN="MokoWaaS Administrator Login" + +; ===== Error messages ===== +JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" + +; ===== Admin-specific branding ===== +COM_ADMIN_VIEW_HOME_TITLE="MokoWaaS Control Panel" +JLIB_APPLICATION_ERROR_SAVE_FAILED="MokoWaaS Error: Save failed" + +; ===== Module list workaround (RegularLabs) ===== +COM_MODULES_HEADING_POSITION="Position" + +; ===== Extensions ===== +COM_INSTALLER_TYPE_TYPE_JOOMLA="MokoWaaS" +COM_INSTALLER_MSG_UPDATE_SUCCESS="Update installed successfully" + +; ===== Dashboard ===== +COM_CPANEL_WELCOME_BEGINNERS_TITLE="Welcome to MokoWaaS!" +COM_CPANEL_WELCOME_BEGINNERS_MESSAGE="

    Community resources are available for new users.

    " +COM_CPANEL_MSG_STATS_COLLECTION_TITLE="Stats Collection in MokoWaaS" + +; ===== Quick Icons ===== +PLG_QUICKICON_JOOMLAUPDATE_CHECKING="Checking MokoWaaS…" +PLG_QUICKICON_JOOMLAUPDATE_ERROR="Unknown MokoWaaS…" +PLG_QUICKICON_JOOMLAUPDATE_UPTODATE="MokoWaaS is up to date." + +; ===== System Info ===== +COM_ADMIN_JOOMLA_VERSION="MokoWaaS Version" +COM_ADMIN_HELP="MokoWaaS Help" +COM_ADMIN_JOOMLA_COMPAT_PLUGIN="MokoWaaS Backward Compatibility Plugin" + +; ===== Installer ===== +COM_INSTALLER_UPLOAD_INSTALL_JOOMLA_EXTENSION="Upload & Install MokoWaaS Extension" +COM_INSTALLER_UNABLE_TO_INSTALL_JOOMLA_PACKAGE="The MokoWaaS package cannot be installed through the Extension Manager. Please use the MokoWaaS Update component to update." +COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTSET="The MokoWaaS temporary folder is not set." +COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTWRITEABLE="The MokoWaaS temporary folder is not writable or does not exist." +COM_INSTALLER_MSG_WARNINGS_UPDATE_NOTICE="Before updating ensure that the update is compatible with your MokoWaaS installation.
    You are strongly advised to make a backup of your site's files and database before you start updating." + +; ===== Global Configuration ===== +COM_CONFIG_FIELD_METAVERSION_LABEL="MokoWaaS Version" + +; ===== Update component ===== +COM_JOOMLAUPDATE_CONFIGURATION="MokoWaaS Update: Options" +COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_NEXT="MokoWaaS Next" +COM_JOOMLAUPDATE_CONFIG_SOURCES_DESC="Configure where MokoWaaS gets its update information from." +COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_LABEL="Update Channel" +COM_JOOMLAUPDATE_VIEW_DEFAULT_TITLE="MokoWaaS Update" +COM_JOOMLAUPDATE_VIEW_DEFAULT_DESCRIPTION="MokoWaaS Update Component" +COM_JOOMLAUPDATE_NOCHANGE="MokoWaaS is up to date." +COM_JOOMLAUPDATE_PREUPDATE_CHECK="MokoWaaS Pre-Update Check" +COM_JOOMLAUPDATE_UPDATE_HEADER="MokoWaaS Update" +COM_JOOMLAUPDATE_LIVEUPDATE="Live Update" +COM_JOOMLAUPDATE_CHECKEDFOR_UPDATES="Checked for MokoWaaS updates." + +; ===== Privacy ===== +COM_PRIVACY_HEADING_CORE_CAPABILITIES="MokoWaaS Core Capabilities" + +; ===== Database & Library errors ===== +JLIB_INSTALLER_MINIMUM_JOOMLA="You don't have the minimum MokoWaaS version requirement of J%s" +JLIB_INSTALLER_ERROR_NOTFINDJOOMLAXMLSETUPFILE="Installer: Can't find MokoWaaS XML setup file." + +; ===== Version and About ===== +JLIB_HTML_POWERED_BY="Powered by MokoWaaS" +COM_ADMIN_HELP_DOCUMENTATION="MokoWaaS Documentation" +COM_ADMIN_HELP_SUPPORT="MokoWaaS Support" + +; ===== Akeeba Ticket System (ATS) ===== +COM_ATS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKETS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKET="MokoWaaS Ticket" +COM_ATS_TITLE_NEWTICKET="New MokoWaaS Ticket" +COM_ATS_TITLE_CATEGORIES="Ticket Categories" +COM_ATS_MSG_TICKET_SAVED="Your MokoWaaS ticket has been saved." +COM_ATS_MSG_TICKET_CLOSED="Your MokoWaaS ticket has been closed." +COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." +COM_ATS_LBL_POWEREDBY="Powered by MokoWaaS" diff --git a/source/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini b/source/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini new file mode 100644 index 00000000..94da3e27 --- /dev/null +++ b/source/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini @@ -0,0 +1,118 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language Overrides +; Ingroup: MokoWaaS +; Version: 02.01.08 +; File: en-US.override.ini +; Path: administrator/language/overrides/en-US.override.ini +; Brief: Admin language overrides β€” values are hardcoded. +; ----------------------------------------------------------------------------- + +; ===== Footer & template branding ===== +TPL_ATUM_POWERED_BY="Powered by MokoWaaS" +MOD_FOOTER_LINE2="Powered by MokoWaaS" + +; ===== Control panel greetings ===== +COM_CPANEL_WELCOME_TITLE="Welcome to MokoWaaS!" +COM_CPANEL_MSG_WELCOME="Welcome to MokoWaaS!" + +; ===== Help/Docs phrasing ===== +COM_ADMIN_HELP_SITE="MokoWaaS Help" +COM_ADMIN_HELPSITE_FIELD_LABEL="MokoWaaS Help" + +; ===== Generic replacements ===== +JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="MokoWaaS Defaults" +COM_INSTALLER_TYPE_JOOMLA="MokoWaaS Package" +LIB_JOOMLA="MokoWaaS Library" + +; ===== System messages ===== +JERROR_JOOMLA="MokoWaaS Error" +JFIELD_JOOMLA_LABEL="MokoWaaS Field" + +; ===== AdminLogin Support ===== +MOD_LOGINSUPPORT_FORUM="Moko Consulting Support" +MOD_LOGINSUPPORT_DOCUMENTATION="MokoWaaS Documentation" +MOD_LOGINSUPPORT_NEWS="Moko Consulting News" +MOD_LOGINSUPPORT_HEADLINE="Need help? Visit Moko Consulting:" +MOD_LOGINSUPPORT_XML_DESCRIPTION="This module displays useful links to Moko Consulting support on the login screen." +TPL_ATUM_BACKEND_LOGIN="MokoWaaS Administrator Login" + +; ===== Error messages ===== +JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" + +; ===== Admin-specific branding ===== +COM_ADMIN_VIEW_HOME_TITLE="MokoWaaS Control Panel" +JLIB_APPLICATION_ERROR_SAVE_FAILED="MokoWaaS Error: Save failed" + +; ===== Module list workaround (RegularLabs) ===== +COM_MODULES_HEADING_POSITION="Position" + +; ===== Extensions ===== +COM_INSTALLER_TYPE_TYPE_JOOMLA="MokoWaaS" +COM_INSTALLER_MSG_UPDATE_SUCCESS="Update installed successfully" + +; ===== Dashboard ===== +COM_CPANEL_WELCOME_BEGINNERS_TITLE="Welcome to MokoWaaS!" +COM_CPANEL_WELCOME_BEGINNERS_MESSAGE="

    Community resources are available for new users.

    " +COM_CPANEL_MSG_STATS_COLLECTION_TITLE="Stats Collection in MokoWaaS" + +; ===== Quick Icons ===== +PLG_QUICKICON_JOOMLAUPDATE_CHECKING="Checking MokoWaaS…" +PLG_QUICKICON_JOOMLAUPDATE_ERROR="Unknown MokoWaaS…" +PLG_QUICKICON_JOOMLAUPDATE_UPTODATE="MokoWaaS is up to date." + +; ===== System Info ===== +COM_ADMIN_JOOMLA_VERSION="MokoWaaS Version" +COM_ADMIN_HELP="MokoWaaS Help" +COM_ADMIN_JOOMLA_COMPAT_PLUGIN="MokoWaaS Backward Compatibility Plugin" + +; ===== Installer ===== +COM_INSTALLER_UPLOAD_INSTALL_JOOMLA_EXTENSION="Upload & Install MokoWaaS Extension" +COM_INSTALLER_UNABLE_TO_INSTALL_JOOMLA_PACKAGE="The MokoWaaS package cannot be installed through the Extension Manager. Please use the MokoWaaS Update component to update." +COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTSET="The MokoWaaS temporary folder is not set." +COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTWRITEABLE="The MokoWaaS temporary folder is not writable or does not exist." +COM_INSTALLER_MSG_WARNINGS_UPDATE_NOTICE="Before updating ensure that the update is compatible with your MokoWaaS installation.
    You are strongly advised to make a backup of your site's files and database before you start updating." + +; ===== Global Configuration ===== +COM_CONFIG_FIELD_METAVERSION_LABEL="MokoWaaS Version" + +; ===== Update component ===== +COM_JOOMLAUPDATE_CONFIGURATION="MokoWaaS Update: Options" +COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_NEXT="MokoWaaS Next" +COM_JOOMLAUPDATE_CONFIG_SOURCES_DESC="Configure where MokoWaaS gets its update information from." +COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_LABEL="Update Channel" +COM_JOOMLAUPDATE_VIEW_DEFAULT_TITLE="MokoWaaS Update" +COM_JOOMLAUPDATE_VIEW_DEFAULT_DESCRIPTION="MokoWaaS Update Component" +COM_JOOMLAUPDATE_NOCHANGE="MokoWaaS is up to date." +COM_JOOMLAUPDATE_PREUPDATE_CHECK="MokoWaaS Pre-Update Check" +COM_JOOMLAUPDATE_UPDATE_HEADER="MokoWaaS Update" +COM_JOOMLAUPDATE_LIVEUPDATE="Live Update" +COM_JOOMLAUPDATE_CHECKEDFOR_UPDATES="Checked for MokoWaaS updates." + +; ===== Privacy ===== +COM_PRIVACY_HEADING_CORE_CAPABILITIES="MokoWaaS Core Capabilities" + +; ===== Database & Library errors ===== +JLIB_INSTALLER_MINIMUM_JOOMLA="You don't have the minimum MokoWaaS version requirement of J%s" +JLIB_INSTALLER_ERROR_NOTFINDJOOMLAXMLSETUPFILE="Installer: Can't find MokoWaaS XML setup file." + +; ===== Version and About ===== +JLIB_HTML_POWERED_BY="Powered by MokoWaaS" +COM_ADMIN_HELP_DOCUMENTATION="MokoWaaS Documentation" +COM_ADMIN_HELP_SUPPORT="MokoWaaS Support" + +; ===== Akeeba Ticket System (ATS) ===== +COM_ATS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKETS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKET="MokoWaaS Ticket" +COM_ATS_TITLE_NEWTICKET="New MokoWaaS Ticket" +COM_ATS_TITLE_CATEGORIES="Ticket Categories" +COM_ATS_MSG_TICKET_SAVED="Your MokoWaaS ticket has been saved." +COM_ATS_MSG_TICKET_CLOSED="Your MokoWaaS ticket has been closed." +COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." +COM_ATS_LBL_POWEREDBY="Powered by MokoWaaS" diff --git a/src/packages/plg_system_mokowaas/Service/index.html b/source/packages/plg_system_mokowaas/administrator/language/overrides/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/Service/index.html rename to source/packages/plg_system_mokowaas/administrator/language/overrides/index.html diff --git a/src/packages/plg_system_mokowaas/forms/alias_entry.xml b/source/packages/plg_system_mokowaas/forms/alias_entry.xml similarity index 100% rename from src/packages/plg_system_mokowaas/forms/alias_entry.xml rename to source/packages/plg_system_mokowaas/forms/alias_entry.xml diff --git a/src/packages/plg_system_mokowaas/index.html b/source/packages/plg_system_mokowaas/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/index.html rename to source/packages/plg_system_mokowaas/index.html diff --git a/src/packages/plg_system_mokowaas/language/en-GB/index.html b/source/packages/plg_system_mokowaas/language/en-GB/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/language/en-GB/index.html rename to source/packages/plg_system_mokowaas/language/en-GB/index.html diff --git a/source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini b/source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini new file mode 100644 index 00000000..fe5a2ac5 --- /dev/null +++ b/source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini @@ -0,0 +1,41 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language +; Ingroup: MokoWaaS +; File: plg_system_mokowaas.ini +; Brief: English language strings for MokoWaaS core system plugin +; ----------------------------------------------------------------------------- + +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, heartbeat, health checks, and admin customizations." + +; ===== Core fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL="Core" +PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC="Heartbeat token for health monitoring and Grafana integration." + +; ===== Diagnostics ===== +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Heartbeat Token" +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." + +; ===== Site Aliases fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases" +PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior." +PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_LABEL="Primary Domain" +PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_DESC="The primary domain for this site (e.g. waas.dev.mokoconsulting.tech). Used for backend redirect on alias domains. Do not include https:// prefix." +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Domain Aliases" +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Add domain aliases that serve as mirrors of this site. Each alias gets its own Grafana monitoring datasource." +PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_LABEL="Domain" +PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_DESC="The alias domain name (e.g. www.example.com). Do not include https:// prefix." +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_LABEL="Offline" +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_DESC="Show an offline maintenance page when visitors access the site through this alias domain." +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_LABEL="Offline Message" +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_DESC="Custom message to display when this alias is set to offline." +PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_LABEL="Robots" +PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_DESC="Meta robots directive for this alias domain. Use 'noindex, nofollow' to prevent search engines from indexing the alias." +PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_LABEL="Redirect Backend" +PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_DESC="Redirect admin panel requests on this alias to the primary domain. Frontend stays on the alias domain." diff --git a/src/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini b/source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini similarity index 77% rename from src/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini rename to source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini index 53cb9993..14f672b5 100644 --- a/src/packages/plg_system_mokowaas/administrator/language/en-GB/plg_system_mokowaas.sys.ini +++ b/source/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.sys.ini @@ -15,5 +15,5 @@ ; Variables: (none) ; ----------------------------------------------------------------------------- -PLG_SYSTEM_MOKOWAAS="System - Moko WaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, master user management, event routing, and admin customizations." diff --git a/src/packages/plg_system_mokowaas/language/en-US/index.html b/source/packages/plg_system_mokowaas/language/en-US/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/language/en-US/index.html rename to source/packages/plg_system_mokowaas/language/en-US/index.html diff --git a/source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini b/source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini new file mode 100644 index 00000000..fe5a2ac5 --- /dev/null +++ b/source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini @@ -0,0 +1,41 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language +; Ingroup: MokoWaaS +; File: plg_system_mokowaas.ini +; Brief: English language strings for MokoWaaS core system plugin +; ----------------------------------------------------------------------------- + +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, heartbeat, health checks, and admin customizations." + +; ===== Core fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL="Core" +PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC="Heartbeat token for health monitoring and Grafana integration." + +; ===== Diagnostics ===== +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Heartbeat Token" +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." + +; ===== Site Aliases fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases" +PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior." +PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_LABEL="Primary Domain" +PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_DESC="The primary domain for this site (e.g. waas.dev.mokoconsulting.tech). Used for backend redirect on alias domains. Do not include https:// prefix." +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Domain Aliases" +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Add domain aliases that serve as mirrors of this site. Each alias gets its own Grafana monitoring datasource." +PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_LABEL="Domain" +PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_DESC="The alias domain name (e.g. www.example.com). Do not include https:// prefix." +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_LABEL="Offline" +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_DESC="Show an offline maintenance page when visitors access the site through this alias domain." +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_LABEL="Offline Message" +PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_DESC="Custom message to display when this alias is set to offline." +PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_LABEL="Robots" +PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_DESC="Meta robots directive for this alias domain. Use 'noindex, nofollow' to prevent search engines from indexing the alias." +PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_LABEL="Redirect Backend" +PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_DESC="Redirect admin panel requests on this alias to the primary domain. Frontend stays on the alias domain." diff --git a/src/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini b/source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini similarity index 77% rename from src/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini rename to source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini index 5e278e69..802ce253 100644 --- a/src/packages/plg_system_mokowaas/administrator/language/en-US/plg_system_mokowaas.sys.ini +++ b/source/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.sys.ini @@ -15,5 +15,5 @@ ; Variables: (none) ; ----------------------------------------------------------------------------- -PLG_SYSTEM_MOKOWAAS="System - Moko WaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." +PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core" +PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin β€” coordinates feature plugins, master user management, event routing, and admin customizations." diff --git a/source/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini b/source/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini new file mode 100644 index 00000000..b33bf0e4 --- /dev/null +++ b/source/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini @@ -0,0 +1,64 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language Overrides +; Ingroup: MokoWaaS +; Version: 02.01.08 +; File: en-GB.override.ini +; Path: language/overrides/en-GB.override.ini +; Brief: Site/frontend language overrides β€” values are hardcoded. +; ----------------------------------------------------------------------------- + +; ===== Footer & template branding ===== +TPL_CASSIOPEIA_POWERED_BY="Powered by MokoWaaS" +MOD_FOOTER_LINE2="Powered by MokoWaaS" + +; ===== Generic replacements ===== +JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="MokoWaaS Defaults" +LIB_JOOMLA="MokoWaaS Library" + +; ===== System messages ===== +JERROR_JOOMLA="MokoWaaS Error" +JFIELD_JOOMLA_LABEL="MokoWaaS Field" + +; ===== Error messages ===== +JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" + +; ===== Installer / Sample data ===== +INSTL_SITE_NAME_LABEL="MokoWaaS Site Name" +INSTL_SAMPLE_BLOG_SET="MokoWaaS Sample Data - Blog" +INSTL_SAMPLE_BROCHURE_SET="MokoWaaS Sample Data - Brochure Site" +INSTL_SAMPLE_DATA_SET="MokoWaaS Sample Data - Default" +INSTL_SAMPLE_LEARN_SET="MokoWaaS Sample Data - Learn" +INSTL_SAMPLE_TESTING_SET="MokoWaaS Sample Data - Testing" + +; ===== Login support ===== +MOD_LOGINSUPPORT_FORUM="Moko Consulting Support" +MOD_LOGINSUPPORT_DOCUMENTATION="MokoWaaS Documentation" +MOD_LOGINSUPPORT_NEWS="Moko Consulting News" + +; ===== Site offline ===== +JOFFLINE_MESSAGE="This site is down for maintenance.
    Please check back again soon." + +; ===== Error pages ===== +JERROR_PAGE_NOT_FOUND="Page Not Found" +JERROR_AN_ERROR_HAS_OCCURRED="An error has occurred." +JLIB_APPLICATION_ERROR_COMPONENT_NOT_FOUND="Component not found." + +; ===== Version and About ===== +JLIB_HTML_POWERED_BY="Powered by MokoWaaS" + +; ===== Akeeba Ticket System (ATS) ===== +COM_ATS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKETS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKET="MokoWaaS Ticket" +COM_ATS_TITLE_NEWTICKET="New MokoWaaS Ticket" +COM_ATS_TITLE_CATEGORIES="Ticket Categories" +COM_ATS_MSG_TICKET_SAVED="Your MokoWaaS ticket has been saved." +COM_ATS_MSG_TICKET_CLOSED="Your MokoWaaS ticket has been closed." +COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." +COM_ATS_LBL_POWEREDBY="Powered by MokoWaaS" diff --git a/source/packages/plg_system_mokowaas/language/overrides/en-US.override.ini b/source/packages/plg_system_mokowaas/language/overrides/en-US.override.ini new file mode 100644 index 00000000..51067385 --- /dev/null +++ b/source/packages/plg_system_mokowaas/language/overrides/en-US.override.ini @@ -0,0 +1,64 @@ +; ----------------------------------------------------------------------------- +; Copyright (C) 2025 Moko Consulting +; This file is part of a Moko Consulting project. +; SPDX-License-Identifier: GPL-3.0-or-later +; REPO: https://github.com/mokoconsulting-tech/mokowaas +; ----------------------------------------------------------------------------- +; FILE INFORMATION +; Defgroup: Joomla Language Overrides +; Ingroup: MokoWaaS +; Version: 02.01.08 +; File: en-US.override.ini +; Path: language/overrides/en-US.override.ini +; Brief: Site/frontend language overrides β€” values are hardcoded. +; ----------------------------------------------------------------------------- + +; ===== Footer & template branding ===== +TPL_CASSIOPEIA_POWERED_BY="Powered by MokoWaaS" +MOD_FOOTER_LINE2="Powered by MokoWaaS" + +; ===== Generic replacements ===== +JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="MokoWaaS Defaults" +LIB_JOOMLA="MokoWaaS Library" + +; ===== System messages ===== +JERROR_JOOMLA="MokoWaaS Error" +JFIELD_JOOMLA_LABEL="MokoWaaS Field" + +; ===== Error messages ===== +JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" + +; ===== Installer / Sample data ===== +INSTL_SITE_NAME_LABEL="MokoWaaS Site Name" +INSTL_SAMPLE_BLOG_SET="MokoWaaS Sample Data - Blog" +INSTL_SAMPLE_BROCHURE_SET="MokoWaaS Sample Data - Brochure Site" +INSTL_SAMPLE_DATA_SET="MokoWaaS Sample Data - Default" +INSTL_SAMPLE_LEARN_SET="MokoWaaS Sample Data - Learn" +INSTL_SAMPLE_TESTING_SET="MokoWaaS Sample Data - Testing" + +; ===== Login support ===== +MOD_LOGINSUPPORT_FORUM="Moko Consulting Support" +MOD_LOGINSUPPORT_DOCUMENTATION="MokoWaaS Documentation" +MOD_LOGINSUPPORT_NEWS="Moko Consulting News" + +; ===== Site offline ===== +JOFFLINE_MESSAGE="This site is down for maintenance.
    Please check back again soon." + +; ===== Error pages ===== +JERROR_PAGE_NOT_FOUND="Page Not Found" +JERROR_AN_ERROR_HAS_OCCURRED="An error has occurred." +JLIB_APPLICATION_ERROR_COMPONENT_NOT_FOUND="Component not found." + +; ===== Version and About ===== +JLIB_HTML_POWERED_BY="Powered by MokoWaaS" + +; ===== Akeeba Ticket System (ATS) ===== +COM_ATS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKETS="MokoWaaS Tickets" +COM_ATS_TITLE_TICKET="MokoWaaS Ticket" +COM_ATS_TITLE_NEWTICKET="New MokoWaaS Ticket" +COM_ATS_TITLE_CATEGORIES="Ticket Categories" +COM_ATS_MSG_TICKET_SAVED="Your MokoWaaS ticket has been saved." +COM_ATS_MSG_TICKET_CLOSED="Your MokoWaaS ticket has been closed." +COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." +COM_ATS_LBL_POWEREDBY="Powered by MokoWaaS" diff --git a/src/packages/plg_system_mokowaas/administrator/language/overrides/index.html b/source/packages/plg_system_mokowaas/language/overrides/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/administrator/language/overrides/index.html rename to source/packages/plg_system_mokowaas/language/overrides/index.html diff --git a/source/packages/plg_system_mokowaas/mokowaas.xml b/source/packages/plg_system_mokowaas/mokowaas.xml new file mode 100644 index 00000000..556ea2ed --- /dev/null +++ b/source/packages/plg_system_mokowaas/mokowaas.xml @@ -0,0 +1,85 @@ + + + + System - MokoWaaS Core + mokowaas + Moko Consulting + 2026-05-22 + Copyright (C) 2025 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE.md + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + MokoWaaS core system plugin β€” coordinates feature plugins, heartbeat, health checks, and admin customizations. + Moko\Plugin\System\MokoWaaS + script.php + + + script.php + Extension + Field + Helper + forms + payload + services + language + administrator + + + + en-GB/plg_system_mokowaas.ini + en-US/plg_system_mokowaas.ini + + + + en-GB/plg_system_mokowaas.sys.ini + en-US/plg_system_mokowaas.sys.ini + + + + + language + + + + + +
    + +
    +
    +
    +
    diff --git a/src/packages/plg_system_mokowaas/media/index.html b/source/packages/plg_system_mokowaas/payload/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/media/index.html rename to source/packages/plg_system_mokowaas/payload/index.html diff --git a/src/packages/plg_system_mokowaas/script.php b/source/packages/plg_system_mokowaas/script.php similarity index 87% rename from src/packages/plg_system_mokowaas/script.php rename to source/packages/plg_system_mokowaas/script.php index dffd284d..7a04de93 100644 --- a/src/packages/plg_system_mokowaas/script.php +++ b/source/packages/plg_system_mokowaas/script.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 + * VERSION: 02.34.16 * PATH: /src/script.php * BRIEF: Installation script for MokoWaaS plugin * NOTE: Handles installation, update, and uninstallation tasks including language override deployment @@ -127,7 +127,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $this->ensureMokoCassiopeia(); $this->installLanguageOverrides(); $this->updateLoginSupportUrls(); - $this->updateAtumBranding(); $this->registerActionLogExtension(); $this->provisionHealthEndpoint(); $this->sendInstallNotification($type); @@ -537,80 +536,12 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface /** Sentinel comment that marks the end of MokoWaaS overrides inside a Joomla override file. */ private const BLOCK_END = '; ===== END MokoWaaS Overrides ====='; - /** - * Build the placeholder β†’ value map from the plugin's saved params. - * - * On first install the params row may not exist yet, so every value - * falls back to a sensible default. - * - * @return array Associative array of placeholder => replacement value - * - * @since 02.01.08 - */ - private function getPlaceholders() - { - $params = $this->getPluginParams(); - - return [ - '{{BRAND_NAME}}' => $params->get('brand_name', 'MokoWaaS'), - '{{COMPANY_NAME}}' => $params->get('company_name', 'Moko Consulting'), - '{{SUPPORT_URL}}' => $params->get('support_url', 'https://mokoconsulting.tech/support'), - ]; - } - - /** - * Load the plugin's saved params from the database. - * - * @return \Joomla\Registry\Registry - * - * @since 02.01.08 - */ - private function getPluginParams() - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('params')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')); - - $db->setQuery($query); - $json = $db->loadResult(); - - return new \Joomla\Registry\Registry($json ?: '{}'); - } - - /** - * Resolve placeholders in an array of language strings. - * - * @param array $strings Key/value pairs (values may contain {{…}} tokens) - * - * @return array The same array with placeholders replaced - * - * @since 02.01.08 - */ - private function resolvePlaceholders(array $strings) - { - $placeholders = $this->getPlaceholders(); - $search = array_keys($placeholders); - $replace = array_values($placeholders); - - foreach ($strings as $key => $value) - { - $strings[$key] = str_replace($search, $replace, $value); - } - - return $strings; - } - /** * Install language override files to Joomla's global override directories. * - * Reads each source override template shipped with the plugin, resolves - * {{BRAND_NAME}} etc. from plugin params, then merges the resolved keys - * into the destination file inside a clearly delimited block. Existing - * overrides outside the block are never touched. + * Reads each source override file shipped with the plugin and merges + * the keys into the destination file inside a clearly delimited block. + * Existing overrides outside the block are never touched. * * @return void * @@ -644,7 +575,7 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface Folder::create($destDir); } - $pluginOverrides = $this->resolvePlaceholders($this->parseLanguageFile($source)); + $pluginOverrides = $this->parseLanguageFile($source); if (empty($pluginOverrides)) { @@ -696,7 +627,7 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $supportUrls = [ 'forum_url' => 'https://mokoconsulting.tech/support', - 'documentation_url' => 'https://mokoconsulting.tech/kb', + 'documentation_url' => 'https://mokoconsulting.tech/support/products', 'news_url' => 'https://mokoconsulting.tech/news', ]; @@ -727,75 +658,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface ); } - /** - * Set Atum admin template branding params at install time. - * - * @return void - * - * @since 02.01.08 - */ - private function updateAtumBranding() - { - $mediaBase = 'media/plg_system_mokowaas/'; - - $expected = [ - 'logoBrandLarge' => $mediaBase . 'logo.png', - 'logoBrandSmall' => $mediaBase . 'favicon_256.png', - 'loginLogo' => $mediaBase . 'logo.png', - 'logoBrandLargeAlt' => '', - 'logoBrandSmallAlt' => '', - 'loginLogoAlt' => '', - 'emptyLogoBrandLargeAlt' => '1', - 'emptyLogoBrandSmallAlt' => '1', - 'emptyLoginLogoAlt' => '1', - 'hue' => 'hsl(219, 44%, 18%)', - 'special-color' => '#1a2744', - 'link-color' => '#0051ad', - ]; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' - . $db->quote('atum')) - ->where($db->quoteName('client_id') . ' = 1'); - - $db->setQuery($query); - $styles = $db->loadObjectList(); - - if (empty($styles)) - { - return; - } - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry( - $style->params ?: '{}' - ); - - foreach ($expected as $key => $value) - { - $params->set($key, $value); - } - - $update = $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $style->id); - - $db->setQuery($update); - $db->execute(); - } - - Factory::getApplication()->enqueueMessage( - 'Updated Atum template branding.', 'message' - ); - } - /** * Register the plugin in #__action_logs_extensions so it appears * as a filterable extension in System > Action Logs. diff --git a/src/packages/plg_system_mokowaas/services/index.html b/source/packages/plg_system_mokowaas/services/index.html similarity index 100% rename from src/packages/plg_system_mokowaas/services/index.html rename to source/packages/plg_system_mokowaas/services/index.html diff --git a/src/packages/plg_system_mokowaas/services/provider.php b/source/packages/plg_system_mokowaas/services/provider.php similarity index 98% rename from src/packages/plg_system_mokowaas/services/provider.php rename to source/packages/plg_system_mokowaas/services/provider.php index 8770e8aa..788578f1 100644 --- a/src/packages/plg_system_mokowaas/services/provider.php +++ b/source/packages/plg_system_mokowaas/services/provider.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 + * VERSION: 02.34.16 * PATH: /src/services/provider.php * BRIEF: Service provider for dependency injection in Joomla 5.x * NOTE: Registers the plugin with Joomla's DI container diff --git a/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini b/source/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini similarity index 82% rename from src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini rename to source/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini index 8fb821be..b4c72a05 100644 --- a/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini +++ b/source/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini @@ -13,3 +13,5 @@ PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_HITS_LABEL="Reset All Hits" PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_HITS_DESC="One-shot: reset article hit counters on save. Automatically turns off after execution." PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DELETE_VERSIONS_LABEL="Delete All Versions" PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DELETE_VERSIONS_DESC="One-shot: delete all content version history on save. Automatically turns off after execution." +PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_DLKEYS_LABEL="Reset Download Keys" +PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_DLKEYS_DESC="One-shot: clear all download keys (dlid) from update sites on save. Automatically turns off after execution." diff --git a/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini b/source/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini similarity index 100% rename from src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini rename to source/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini diff --git a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml b/source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml similarity index 85% rename from src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml rename to source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml index 1f432dd3..29d3f160 100644 --- a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml +++ b/source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC Moko\Plugin\System\MokoWaaSDevTools @@ -52,6 +52,14 @@ + + + + + diff --git a/src/packages/plg_system_mokowaas_devtools/services/provider.php b/source/packages/plg_system_mokowaas_devtools/services/provider.php similarity index 100% rename from src/packages/plg_system_mokowaas_devtools/services/provider.php rename to source/packages/plg_system_mokowaas_devtools/services/provider.php diff --git a/src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php b/source/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php similarity index 69% rename from src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php rename to source/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php index cc6f5a90..06067d9d 100644 --- a/src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php +++ b/source/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php @@ -80,9 +80,17 @@ class DevTools extends CMSPlugin implements SubscriberInterface */ public function onExtensionAfterSave($event): void { - $context = $event->getArgument(0, ''); - $table = $event->getArgument(1); - $isNew = $event->getArgument(2, false); + // Joomla 6: single event object; Joomla 5: individual args + if (is_object($event) && method_exists($event, 'getArgument')) + { + $context = $event->getArgument('context', $event->getArgument(0, '')); + $table = $event->getArgument('subject', $event->getArgument(1, null)); + } + else + { + $context = $event; + $table = func_get_arg(1); + } if ($context !== 'com_plugins.plugin' || !$table) { @@ -111,6 +119,13 @@ class DevTools extends CMSPlugin implements SubscriberInterface $params->set('delete_versions', 0); } + // Reset download keys on save if toggled on + if ($params->get('reset_download_keys', 0)) + { + $this->resetDownloadKeys(); + $params->set('reset_download_keys', 0); + } + // Reset the one-shot toggles if ($table->params !== $params->toString()) { @@ -152,4 +167,41 @@ class DevTools extends CMSPlugin implements SubscriberInterface return $count; } + + private function resetDownloadKeys(): int + { + $db = Factory::getDbo(); + + // Find update sites that have a dlid in extra_query + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) + ->from($db->quoteName('#__update_sites')) + ->where($db->quoteName('extra_query') . ' LIKE ' . $db->quote('%dlid=%')) + ); + + $sites = $db->loadObjectList(); + $count = 0; + + foreach ($sites as $site) + { + // Parse the query string, remove dlid, rebuild + parse_str($site->extra_query, $parsed); + unset($parsed['dlid']); + $newQuery = http_build_query($parsed); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote($newQuery)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $site->update_site_id) + )->execute(); + + $count++; + } + + $this->getApplication()->enqueueMessage(\sprintf('Cleared download keys from %d update sites.', $count), 'message'); + + return $count; + } } diff --git a/source/packages/plg_system_mokowaas_firewall/forms/trusted_ip_entry.xml b/source/packages/plg_system_mokowaas_firewall/forms/trusted_ip_entry.xml new file mode 100644 index 00000000..e3850414 --- /dev/null +++ b/source/packages/plg_system_mokowaas_firewall/forms/trusted_ip_entry.xml @@ -0,0 +1,16 @@ + +
    + + + + + + + diff --git a/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini b/source/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini similarity index 96% rename from src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini rename to source/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini index 2d544bc2..7c62e5ae 100644 --- a/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini +++ b/source/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini @@ -31,6 +31,9 @@ PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_RFI_DESC="Block remote file inclusion attempts PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_LABEL="DFIShield" PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_DESC="Block directory traversal and local file inclusion attempts." +PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS="Security Headers" +PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS_DESC="HTTP security headers injected into every response." + PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS="Access Control" PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS_DESC="IP blocking, admin secret URL, and login restrictions." PLG_SYSTEM_MOKOWAAS_FIREWALL_IP_BLOCKLIST_LABEL="IP Deny List" diff --git a/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini b/source/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini similarity index 100% rename from src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini rename to source/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini diff --git a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml b/source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml similarity index 75% rename from src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml rename to source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml index e01bdbea..b9c8e69a 100644 --- a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml +++ b/source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC Moko\Plugin\System\MokoWaaSFirewall @@ -16,6 +16,7 @@ src sql services + forms language @@ -54,7 +55,7 @@ + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + params->get('admin_session_timeout', 0); + + if ($timeout <= 0) + { + return; + } + + if ($this->ipIsTrusted()) + { + ini_set('session.gc_maxlifetime', 315360000); + Factory::getConfig()->set('lifetime', 525600); + } + } + private const BLOCKED_FILES = [ 'htaccess.txt', 'web.config.txt', 'configuration.php-dist', 'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist', @@ -44,6 +65,7 @@ class Firewall extends CMSPlugin implements SubscriberInterface { return [ 'onAfterInitialise' => 'onAfterInitialise', + 'onAfterRoute' => 'onAfterRoute', 'onUserBeforeSave' => 'onUserBeforeSave', ]; } @@ -90,7 +112,14 @@ class Firewall extends CMSPlugin implements SubscriberInterface $this->checkDirectPhpAccess(); } - // Existing features + // Block super users from frontend + if ($app->isClient('site') && $this->params->get('block_frontend_superuser', 0)) + { + $this->blockFrontendSuperuser(); + } + + // Security headers + existing features + $this->injectSecurityHeaders(); $this->enforceHttps(); $this->enforceUploadRestrictions(); @@ -316,6 +345,19 @@ class Firewall extends CMSPlugin implements SubscriberInterface } } + /** + * Redirect super admin users away from the frontend to the admin panel. + */ + private function blockFrontendSuperuser(): void + { + $user = Factory::getApplication()->getIdentity(); + + if ($user && $user->id && $user->authorise('core.admin')) + { + Factory::getApplication()->redirect(Route::_('administrator/index.php', false)); + } + } + private function checkAdminSecret(): void { $secret = $this->params->get('admin_secret', ''); @@ -379,6 +421,46 @@ class Firewall extends CMSPlugin implements SubscriberInterface 'created' => gmdate('Y-m-d H:i:s'), ]; $db->insertObject('#__mokowaas_waf_log', $row); + + // Security alert email (#131) β€” rate limited to 1 per IP per 5 minutes + try + { + $alertKey = 'mokowaas_waf_alert_' . md5($ip); + $session = \Joomla\CMS\Factory::getSession(); + + if (!$session->get($alertKey, false)) + { + $session->set($alertKey, true); + \Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert( + 'waf_block', + 'WAF Block: ' . $rule . ' from ' . $ip, + "Rule: {$rule}\nIP: {$ip}\nURI: {$uri}\nDetail: " . substr($detail, 0, 200) + ); + } + } + catch (\Throwable $e) {} + + // Auto-ban: if IP has N+ blocks in last M minutes, add to blocklist (#143) + $threshold = (int) $this->params->get('autoban_threshold', 10); + $window = (int) $this->params->get('autoban_window', 5); + + if ($threshold > 0 && $window > 0) + { + $cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60)); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('ip') . ' = ' . $db->quote($ip)) + ->where($db->quoteName('created') . ' >= ' . $db->quote($cutoff)) + ); + $recentBlocks = (int) $db->loadResult(); + + if ($recentBlocks >= $threshold) + { + $this->autoBanIp($ip, $db); + } + } } catch (\Throwable $e) { @@ -397,6 +479,51 @@ class Firewall extends CMSPlugin implements SubscriberInterface // Input Scanning // ================================================================== + /** + * Auto-ban an IP by adding it to the blocklist params (#143). + */ + private function autoBanIp(string $ip, $db): void + { + try + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}'); + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + foreach ($blocklist as $entry) + { + if (($entry['ip'] ?? '') === $ip) + { + return; + } + } + + $blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => 'Auto-banned by WAF (' . gmdate('Y-m-d H:i') . ')']; + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + Log::add('WAF auto-banned IP: ' . $ip, Log::WARNING, 'mokowaas'); + } + catch (\Throwable $e) + { + // Silent + } + } + private function scanInput(array $input, string $pattern): ?string { foreach ($input as $key => $value) @@ -414,7 +541,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface } $value = (string) $value; - $decoded = urldecode($value); + // Double-decode to catch %25xx encoding tricks + $decoded = urldecode(urldecode($value)); if (preg_match($pattern, $value) || preg_match($pattern, $decoded)) { @@ -526,6 +654,68 @@ class Firewall extends CMSPlugin implements SubscriberInterface } } + /** + * Inject HTTP security headers at runtime (#124). + */ + private function injectSecurityHeaders(): void + { + $app = $this->getApplication(); + + if ($app->isClient('cli')) + { + return; + } + + if ($this->params->get('header_xframe', 1)) + { + $app->setHeader('X-Frame-Options', 'SAMEORIGIN', true); + } + + if ($this->params->get('header_xcontent', 1)) + { + $app->setHeader('X-Content-Type-Options', 'nosniff', true); + } + + if ($this->params->get('header_xxss', 1)) + { + $app->setHeader('X-XSS-Protection', '1; mode=block', true); + } + + $referrer = $this->params->get('header_referrer', ''); + + if (!empty($referrer) && $referrer !== 'off') + { + $app->setHeader('Referrer-Policy', $referrer, true); + } + + if ($this->params->get('header_hsts', 0)) + { + $maxAge = (int) $this->params->get('header_hsts_maxage', 31536000); + $hsts = 'max-age=' . $maxAge; + + if ($this->params->get('header_hsts_subdomains', 0)) + { + $hsts .= '; includeSubDomains'; + } + + $app->setHeader('Strict-Transport-Security', $hsts, true); + } + + $csp = $this->params->get('header_csp', ''); + + if (!empty($csp)) + { + $app->setHeader('Content-Security-Policy', $csp, true); + } + + $perms = $this->params->get('header_permissions', ''); + + if (!empty($perms)) + { + $app->setHeader('Permissions-Policy', $perms, true); + } + } + private function enforceHttps(): void { if (!$this->params->get('force_https', 0)) @@ -622,4 +812,177 @@ class Firewall extends CMSPlugin implements SubscriberInterface $config->set('upload_maxsize', $maxMb); } } + + // ================================================================== + // Extension Protection (#155) + // ================================================================== + + /** + * Protect MokoWaaS extensions after routing. + * + * @return void + * + * @since 02.35.00 + */ + public function onAfterRoute(): void + { + $app = $this->getApplication(); + + if (!$app->isClient('administrator')) + { + return; + } + + $this->protectPlugin(); + } + + /** + * Protect the plugin from being disabled or uninstalled by non-master users. + * Does NOT self-heal (no lock) -- master users can still disable if needed. + * + * @return void + * + * @since 02.03.04 + */ + private function protectPlugin(): void + { + // Ensure protected flag is set (self-healing -- runs once per session) + static $flagChecked = false; + + if (!$flagChecked) + { + $flagChecked = true; + $this->ensureProtectedFlag(); + } + + if (MokoWaaSHelper::isMasterUser()) + { + return; + } + + $app = $this->getApplication(); + $option = $app->input->get('option', ''); + $task = $app->input->get('task', ''); + + // Block non-master from uninstalling MokoWaaS + if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false) + { + $cid = $app->input->get('cid', [], 'array'); + + if ($this->isOurExtension($cid)) + { + $app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error'); + $app->redirect('index.php?option=com_installer&view=manage'); + } + } + + // Block non-master from disabling via list toggle + if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false) + { + $cid = $app->input->get('cid', [], 'array'); + + if ($this->isOurExtension($cid)) + { + $app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); + $app->redirect('index.php?option=com_plugins'); + } + } + + // Block non-master from viewing or editing MokoWaaS plugin settings + if ($option === 'com_plugins') + { + $view = $app->input->get('view', ''); + $layout = $app->input->get('layout', ''); + $extensionId = (int) $app->input->get('extension_id', 0); + + if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0) + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extensionId) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')); + + if ((int) $db->setQuery($query)->loadResult() > 0) + { + $app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning'); + $app->redirect('index.php?option=com_plugins'); + } + } + } + } + + /** + * Ensure the protected flag is set on MokoWaaS extensions in the DB. + * + * Sets protected=1, locked=0 so the extension can't be disabled or + * uninstalled but can still receive updates and config changes. + * + * @return void + * + * @since 02.03.10 + */ + private function ensureProtectedFlag(): void + { + try + { + $db = Factory::getDbo(); + + // Set protected=1, locked=0 on MokoWaaS extensions + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 1') + ->set($db->quoteName('locked') . ' = 0') + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') + . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')') + ->where($db->quoteName('protected') . ' = 0'); + $db->setQuery($query); + $db->execute(); + + // Ensure update site stays enabled (protected extensions get their update site disabled by Joomla) + $query = $db->getQuery(true) + ->update($db->quoteName('#__update_sites') . ' AS us') + ->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id') + ->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id') + ->set('us.enabled = 1') + ->where('us.enabled = 0') + ->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas') + . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); + $db->setQuery($query); + $db->execute(); + } + catch (\Throwable $e) + { + // Non-critical + } + } + + /** + * Check if any of the given extension IDs belong to MokoWaaS. + * + * @param array $ids Extension IDs to check + * + * @return bool + * + * @since 02.03.04 + */ + private function isOurExtension(array $ids): bool + { + if (empty($ids)) + { + return false; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')') + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') + . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); + + return (int) $db->setQuery($query)->loadResult() > 0; + } } diff --git a/source/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini b/source/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini new file mode 100644 index 00000000..9be4380c --- /dev/null +++ b/source/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini @@ -0,0 +1,13 @@ +; MokoWaaS Health Monitor Plugin +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_SYSTEM_MOKOWAAS_MONITOR="System - MokoWaaS Monitor" +PLG_SYSTEM_MOKOWAAS_MONITOR_DESC="Sends heartbeat data to a MokoWaaSBase control panel for centralized site monitoring." + +PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC="Monitoring" +PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC_DESC="Configure heartbeat reporting to MokoWaaSBase." +PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_LABEL="Send Heartbeat" +PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_DESC="Send heartbeat data to MokoWaaSBase when plugin settings are saved." +PLG_SYSTEM_MOKOWAAS_MONITOR_BASE_URL_LABEL="MokoWaaSBase URL" +PLG_SYSTEM_MOKOWAAS_MONITOR_BASE_URL_DESC="URL of the MokoWaaSBase control panel (e.g. https://mokoconsulting.tech). The heartbeat is sent to /api/index.php/v1/mokowaasbase/heartbeat on this host." diff --git a/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini b/source/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini similarity index 100% rename from src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini rename to source/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini diff --git a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml b/source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml similarity index 81% rename from src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml rename to source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml index b074eb8c..38a213d1 100644 --- a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml +++ b/source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 PLG_SYSTEM_MOKOWAAS_MONITOR_DESC Moko\Plugin\System\MokoWaaSMonitor @@ -36,6 +36,14 @@ + +
    diff --git a/src/packages/plg_system_mokowaas_monitor/services/provider.php b/source/packages/plg_system_mokowaas_monitor/services/provider.php similarity index 100% rename from src/packages/plg_system_mokowaas_monitor/services/provider.php rename to source/packages/plg_system_mokowaas_monitor/services/provider.php diff --git a/source/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php b/source/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php new file mode 100644 index 00000000..addcb36a --- /dev/null +++ b/source/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php @@ -0,0 +1,242 @@ + 'onExtensionAfterSave', + ]; + } + + /** + * After saving this plugin or the core plugin, send heartbeat. + */ + public function onExtensionAfterSave($event): void + { + // Joomla 6: single event object; Joomla 5: individual args + if (is_object($event) && method_exists($event, 'getArgument')) + { + $context = $event->getArgument('context', $event->getArgument(0, '')); + $table = $event->getArgument('subject', $event->getArgument(1, null)); + } + else + { + $context = $event; + $table = func_get_arg(1); + } + + if ($context !== 'com_plugins.plugin' || !$table) + { + return; + } + + $element = $table->element ?? ''; + + // Trigger heartbeat when core or monitor plugin is saved + if (!\in_array($element, ['mokowaas', 'mokowaas_monitor'], true)) + { + return; + } + + if (!$this->params->get('heartbeat_enabled', 1)) + { + return; + } + + $this->sendHeartbeat(); + } + + /** + * Send heartbeat to the MokoWaaSBase control panel. + * + * Posts site identity and version info to the MokoWaaSBase REST API. + * The control panel looks up the site by domain and verifies the token. + */ + private function sendHeartbeat(): void + { + $baseUrl = rtrim($this->params->get('base_url', ''), '/'); + + if (empty($baseUrl)) + { + return; + } + + $coreParams = MokoWaaSHelper::getCoreParams(); + $healthToken = $coreParams->get('health_api_token', ''); + + if (empty($healthToken)) + { + return; + } + + $siteUrl = rtrim(Uri::root(), '/'); + $domain = parse_url($siteUrl, PHP_URL_HOST) ?: ''; + + if (empty($domain)) + { + return; + } + + $app = $this->getApplication(); + + $config = Factory::getConfig(); + + $payload = [ + 'token' => $healthToken, + 'domain' => $domain, + 'site_name' => $config->get('sitename', 'Joomla'), + 'site_url' => $siteUrl, + 'joomla_version' => (new Version())->getShortVersion(), + 'php_version' => PHP_VERSION, + 'mokowaas_version' => $this->getMokoWaaSVersion(), + 'client_info' => [ + 'company' => $config->get('sitename', ''), + 'email' => $config->get('mailfrom', ''), + ], + ]; + + // Include live health data by calling the local health endpoint + $healthData = $this->fetchLocalHealth($siteUrl, $healthToken); + + if ($healthData !== null) + { + $payload['health'] = $healthData; + } + + $endpoint = $baseUrl . '/api/index.php/v1/mokowaasbase/heartbeat'; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + + $ch = curl_init($endpoint); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => $json, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + ]); + + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) + { + Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); + } + elseif ($code >= 200 && $code < 300) + { + $body = json_decode($response, true); + $app->enqueueMessage( + 'MokoWaaSBase heartbeat: ' . ($body['status'] ?? 'ok'), + 'message' + ); + } + else + { + $body = json_decode($response, true); + Log::add( + \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), + Log::WARNING, + 'mokowaas' + ); + $app->enqueueMessage( + 'MokoWaaSBase heartbeat failed (HTTP ' . $code . ')', + 'warning' + ); + } + } + + /** + * Fetch health data from the local site's health endpoint. + * + * @param string $siteUrl Local site URL. + * @param string $healthToken Health API token. + * + * @return array|null Parsed health data or null on failure. + */ + private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array + { + $url = $siteUrl . '/?mokowaas=health'; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $healthToken, + 'Accept: application/json', + ], + ]); + + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || empty($response)) + { + return null; + } + + return json_decode($response, true) ?: null; + } + + /** + * Get the installed MokoWaaS package version. + */ + private function getMokoWaaSVersion(): string + { + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('manifest_cache')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('package')) + ); + $mc = json_decode($db->loadResult() ?? '{}'); + + return $mc->version ?? ''; + } + catch (\Throwable $e) + { + return ''; + } + } +} diff --git a/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini b/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini new file mode 100644 index 00000000..65517993 --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini @@ -0,0 +1,13 @@ +; MokoWaaS Terms of Service Plugin +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass" +PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode." + +PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages" +PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online" +PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple." +PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items" +PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items." +PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active." diff --git a/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini b/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini new file mode 100644 index 00000000..7b6f1ef3 --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini @@ -0,0 +1,3 @@ +; MokoWaaS Terms of Service Plugin - System strings +PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass" +PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode." diff --git a/source/packages/plg_system_mokowaas_offline/mokowaas_offline.xml b/source/packages/plg_system_mokowaas_offline/mokowaas_offline.xml new file mode 100644 index 00000000..83cf31c3 --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/mokowaas_offline.xml @@ -0,0 +1,44 @@ + + + System - MokoWaaS Offline Bypass + mokowaas_offline + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC + Moko\Plugin\System\MokoWaaSOffline + + + src + services + language + + + + en-GB/plg_system_mokowaas_offline.ini + en-GB/plg_system_mokowaas_offline.sys.ini + + + + +
    + + + + + + +
    +
    +
    +
    diff --git a/source/packages/plg_system_mokowaas_offline/services/provider.php b/source/packages/plg_system_mokowaas_offline/services/provider.php new file mode 100644 index 00000000..c45e733d --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/services/provider.php @@ -0,0 +1,34 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokowaas_offline/src/Extension/Tos.php b/source/packages/plg_system_mokowaas_offline/src/Extension/Tos.php new file mode 100644 index 00000000..669b8137 --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/src/Extension/Tos.php @@ -0,0 +1,179 @@ + 'onAfterInitialise', + ]; + } + + public function onAfterInitialise(): void + { + $app = $this->getApplication(); + + if (!$app->isClient('site')) + { + return; + } + + $config = $app->getConfig(); + + if (!$config->get('offline')) + { + return; + } + + $slugs = $this->params->get('tos_slug', []); + + if (\is_string($slugs)) + { + $slugs = array_filter([trim($slugs)]); + } + else + { + $slugs = (array) $slugs; + } + + // Default bypassed pages when none configured + if (empty($slugs)) + { + $slugs = [ + 'legal/terms-of-service', + 'legal/privacy-policy', + 'legal/community-guidelines', + 'support', + 'support/tickets', + 'support/submit-a-ticket', + ]; + } + + $includeChildren = (int) $this->params->get('include_children', 1); + + if ($this->matchByPath($slugs, $config, $app, $includeChildren)) + { + return; + } + + $this->matchByItemId($slugs, $config, $app, $includeChildren); + } + + private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool + { + $uri = Uri::getInstance(); + $path = urldecode(trim($uri->getPath(), '/')); + + $base = trim(Uri::base(true), '/'); + + if (!empty($base) && strpos($path, $base) === 0) + { + $path = trim(substr($path, \strlen($base)), '/'); + } + + if (empty($path) || $path === 'index.php') + { + return false; + } + + foreach ($slugs as $slug) + { + $slug = trim((string) $slug); + + if (empty($slug)) + { + continue; + } + + if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0)) + { + $this->bypassOffline($config, $app); + + return true; + } + } + + return false; + } + + private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool + { + $itemId = (int) $app->getInput()->getInt('Itemid', 0); + + if (!$itemId) + { + return false; + } + + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('path')) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('id') . ' = ' . $itemId) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('client_id') . ' = 0'); + $db->setQuery($query); + $menuPath = trim((string) $db->loadResult(), '/'); + + if (empty($menuPath)) + { + return false; + } + + foreach ($slugs as $slug) + { + $slug = trim((string) $slug); + + if (empty($slug)) + { + continue; + } + + if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0)) + { + $this->bypassOffline($config, $app); + + return true; + } + } + } + catch (\Throwable $e) + { + // Silent + } + + return false; + } + + private function bypassOffline($config, $app): void + { + $config->set('offline', 0); + } +} diff --git a/source/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php b/source/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php new file mode 100644 index 00000000..d9a822f2 --- /dev/null +++ b/source/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php @@ -0,0 +1,81 @@ +get('sef', true); + + if (!$sef) + { + $options[] = (object) [ + 'value' => '', + 'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'), + 'disabled' => true, + ]; + } + } + catch (\Throwable $e) + { + // Ignore + } + + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['path', 'alias', 'title', 'menutype'])) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('alias') . ' != ' . $db->quote('')) + ->order($db->quoteName('menutype') . ', ' . $db->quoteName('title')); + $db->setQuery($query); + $menuItems = $db->loadObjectList(); + + $lastMenuType = ''; + + foreach ($menuItems ?: [] as $item) + { + if ($item->menutype !== $lastMenuType) + { + if ($lastMenuType !== '') + { + $options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true]; + } + + $lastMenuType = $item->menutype; + } + + $label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias)); + $options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')']; + } + } + catch (\Throwable $e) + { + // Silent + } + + return $options; + } +} diff --git a/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini b/source/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini similarity index 100% rename from src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini rename to source/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini diff --git a/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini b/source/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini similarity index 100% rename from src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini rename to source/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini diff --git a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml b/source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml similarity index 99% rename from src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml rename to source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml index 9609d33c..16e9028c 100644 --- a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml +++ b/source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 PLG_SYSTEM_MOKOWAAS_TENANT_DESC Moko\Plugin\System\MokoWaaSTenant diff --git a/src/packages/plg_system_mokowaas_tenant/services/provider.php b/source/packages/plg_system_mokowaas_tenant/services/provider.php similarity index 100% rename from src/packages/plg_system_mokowaas_tenant/services/provider.php rename to source/packages/plg_system_mokowaas_tenant/services/provider.php diff --git a/src/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php b/source/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php similarity index 100% rename from src/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php rename to source/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php diff --git a/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini b/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini new file mode 100644 index 00000000..5b695de4 --- /dev/null +++ b/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini @@ -0,0 +1,4 @@ +PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules." +PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)." diff --git a/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini b/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini new file mode 100644 index 00000000..c0dc6562 --- /dev/null +++ b/source/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini @@ -0,0 +1,2 @@ +PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules β€” auto-close, SLA escalation, and time-based actions." diff --git a/source/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml b/source/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml new file mode 100644 index 00000000..2a6a31aa --- /dev/null +++ b/source/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml @@ -0,0 +1,25 @@ + + + Task - MokoWaaS Ticket Automation + mokowaas_tickets + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.15 + Runs scheduled helpdesk automation rules β€” auto-close resolved tickets, SLA breach escalation, and time-based actions. + Moko\Plugin\Task\MokoWaaSTickets + + + src + services + language + + + + en-GB/plg_task_mokowaas_tickets.ini + en-GB/plg_task_mokowaas_tickets.sys.ini + + diff --git a/source/packages/plg_task_mokowaas_tickets/services/provider.php b/source/packages/plg_task_mokowaas_tickets/services/provider.php new file mode 100644 index 00000000..e97c8c8e --- /dev/null +++ b/source/packages/plg_task_mokowaas_tickets/services/provider.php @@ -0,0 +1,27 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokowaas_tickets')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php b/source/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php new file mode 100644 index 00000000..3daa7aec --- /dev/null +++ b/source/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php @@ -0,0 +1,65 @@ + [ + 'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION', + 'method' => 'runAutomation', + ], + ]; + + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * Run all scheduled automation rules against open tickets. + */ + private function runAutomation(ExecuteTaskEvent $event): int + { + try + { + $model = new TicketsModel(); + $results = $model->runScheduledAutomation(); + + $this->logTask( + \sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted']) + ); + + return Status::OK; + } + catch (\Throwable $e) + { + $this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error'); + + return Status::KNOCKOUT; + } + } +} diff --git a/src/packages/plg_task_mokowaasdemo/forms/reset_params.xml b/source/packages/plg_task_mokowaasdemo/forms/reset_params.xml similarity index 100% rename from src/packages/plg_task_mokowaasdemo/forms/reset_params.xml rename to source/packages/plg_task_mokowaasdemo/forms/reset_params.xml diff --git a/src/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.ini b/source/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.ini similarity index 100% rename from src/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.ini rename to source/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.ini diff --git a/src/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.sys.ini b/source/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.sys.ini similarity index 100% rename from src/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.sys.ini rename to source/packages/plg_task_mokowaasdemo/language/en-GB/plg_task_mokowaasdemo.sys.ini diff --git a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml b/source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml similarity index 95% rename from src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml rename to source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml index c5f0fb25..d2ed8872 100644 --- a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml +++ b/source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml @@ -12,8 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.34.15 PLG_TASK_MOKOWAASDEMO_DESC Moko\Plugin\Task\MokoWaaSDemo diff --git a/src/packages/plg_task_mokowaasdemo/services/provider.php b/source/packages/plg_task_mokowaasdemo/services/provider.php similarity index 100% rename from src/packages/plg_task_mokowaasdemo/services/provider.php rename to source/packages/plg_task_mokowaasdemo/services/provider.php diff --git a/src/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php b/source/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php similarity index 77% rename from src/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php rename to source/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php index d157d1fc..8c5fbdc6 100644 --- a/src/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php +++ b/source/packages/plg_task_mokowaasdemo/src/Extension/DemoReset.php @@ -16,6 +16,7 @@ use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Task\Status; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Event\SubscriberInterface; +use Moko\Plugin\Task\MokoWaaSDemo\Service\DemoResetService; /** * MokoWaaS Demo Reset β€” Joomla Scheduled Task Plugin. @@ -87,27 +88,20 @@ final class DemoReset extends CMSPlugin implements SubscriberInterface if (!empty($params['take_snapshot_on_save']) && (int) $params['take_snapshot_on_save'] === 1) { - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php'; + $media = !empty($params['include_media']) && (int) $params['include_media'] === 1; + $service = new DemoResetService($media); - if (file_exists($serviceFile)) + try { - require_once $serviceFile; - - $media = !empty($params['include_media']) && (int) $params['include_media'] === 1; - $service = new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media); - - try - { - $result = $service->createSnapshot('default'); - Factory::getApplication()->enqueueMessage( - sprintf('Demo snapshot created (%.1f MB database, media=%s).', $result['dump_size_mb'] ?? 0, ($result['has_media'] ?? false) ? 'yes' : 'no'), - 'message' - ); - } - catch (\Throwable $e) - { - Factory::getApplication()->enqueueMessage('Snapshot failed: ' . $e->getMessage(), 'error'); - } + $result = $service->createSnapshot('default'); + Factory::getApplication()->enqueueMessage( + sprintf('Demo snapshot created (%.1f MB database, media=%s).', $result['dump_size_mb'] ?? 0, ($result['has_media'] ?? false) ? 'yes' : 'no'), + 'message' + ); + } + catch (\Throwable $e) + { + Factory::getApplication()->enqueueMessage('Snapshot failed: ' . $e->getMessage(), 'error'); } // Reset the flag @@ -128,19 +122,8 @@ final class DemoReset extends CMSPlugin implements SubscriberInterface { $params = $event->getArgument('params'); - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php'; - - if (!file_exists($serviceFile)) - { - $this->logTask('DemoResetService.php not found'); - - return Status::KNOCKOUT; - } - - require_once $serviceFile; - $media = !empty($params->include_media) && (int) $params->include_media === 1; - $service = new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media); + $service = new DemoResetService($media); try { @@ -168,17 +151,8 @@ final class DemoReset extends CMSPlugin implements SubscriberInterface */ private function takeSnapshot(object $params): void { - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/DemoResetService.php'; - - if (!file_exists($serviceFile)) - { - return; - } - - require_once $serviceFile; - $media = !empty($params->include_media) && (int) $params->include_media === 1; - $service = new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($media); + $service = new DemoResetService($media); $service->createSnapshot('default'); } diff --git a/src/packages/plg_system_mokowaas/Service/DemoResetService.php b/source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php similarity index 99% rename from src/packages/plg_system_mokowaas/Service/DemoResetService.php rename to source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php index ce458abd..89793892 100644 --- a/src/packages/plg_system_mokowaas/Service/DemoResetService.php +++ b/source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php @@ -10,11 +10,11 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php - * VERSION: 02.33.00 + * VERSION: 02.34.08 * BRIEF: Content-only snapshot/restore for demo site reset */ -namespace Moko\Plugin\System\MokoWaaS\Service; +namespace Moko\Plugin\Task\MokoWaaSDemo\Service; defined('_JEXEC') or die; diff --git a/src/packages/plg_task_mokowaassync/forms/sync_params.xml b/source/packages/plg_task_mokowaassync/forms/sync_params.xml similarity index 100% rename from src/packages/plg_task_mokowaassync/forms/sync_params.xml rename to source/packages/plg_task_mokowaassync/forms/sync_params.xml diff --git a/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini b/source/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini similarity index 100% rename from src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini rename to source/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini diff --git a/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.sys.ini b/source/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.sys.ini similarity index 100% rename from src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.sys.ini rename to source/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.sys.ini diff --git a/src/packages/plg_task_mokowaassync/mokowaassync.xml b/source/packages/plg_task_mokowaassync/mokowaassync.xml similarity index 97% rename from src/packages/plg_task_mokowaassync/mokowaassync.xml rename to source/packages/plg_task_mokowaassync/mokowaassync.xml index 05ca075b..32eb6c7b 100644 --- a/src/packages/plg_task_mokowaassync/mokowaassync.xml +++ b/source/packages/plg_task_mokowaassync/mokowaassync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.34.15 PLG_TASK_MOKOWAASSYNC_DESC Moko\Plugin\Task\MokoWaaSSync diff --git a/src/packages/plg_task_mokowaassync/services/provider.php b/source/packages/plg_task_mokowaassync/services/provider.php similarity index 100% rename from src/packages/plg_task_mokowaassync/services/provider.php rename to source/packages/plg_task_mokowaassync/services/provider.php diff --git a/src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php b/source/packages/plg_task_mokowaassync/src/Extension/ContentSync.php similarity index 100% rename from src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php rename to source/packages/plg_task_mokowaassync/src/Extension/ContentSync.php diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php similarity index 99% rename from src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php rename to source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php index e6c69177..843725ab 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php @@ -10,11 +10,11 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php - * VERSION: 02.33.00 + * VERSION: 02.34.08 * BRIEF: Receiver-side content sync β€” applies incoming payload to local DB */ -namespace Moko\Plugin\System\MokoWaaS\Service; +namespace Moko\Plugin\Task\MokoWaaSSync\Service; defined('_JEXEC') or die; diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php similarity index 99% rename from src/packages/plg_system_mokowaas/Service/ContentSyncService.php rename to source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php index d27bf9a8..8360bf29 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php @@ -10,11 +10,11 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php - * VERSION: 02.33.00 + * VERSION: 02.34.08 * BRIEF: Sender-side content sync β€” builds payload and pushes to remote sites */ -namespace Moko\Plugin\System\MokoWaaS\Service; +namespace Moko\Plugin\Task\MokoWaaSSync\Service; defined('_JEXEC') or die; diff --git a/src/packages/plg_webservices_mokowaas/mokowaas.xml b/source/packages/plg_webservices_mokowaas/mokowaas.xml similarity index 92% rename from src/packages/plg_webservices_mokowaas/mokowaas.xml rename to source/packages/plg_webservices_mokowaas/mokowaas.xml index a23ea1d2..cc42a073 100644 --- a/src/packages/plg_webservices_mokowaas/mokowaas.xml +++ b/source/packages/plg_webservices_mokowaas/mokowaas.xml @@ -7,8 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.34.15 Joomla Web Services API routes for MokoWaaS site management β€” health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoWaaS diff --git a/src/packages/plg_webservices_mokowaas/services/provider.php b/source/packages/plg_webservices_mokowaas/services/provider.php similarity index 100% rename from src/packages/plg_webservices_mokowaas/services/provider.php rename to source/packages/plg_webservices_mokowaas/services/provider.php diff --git a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php b/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php similarity index 90% rename from src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php rename to source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php index ceaec7cb..37235506 100644 --- a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php +++ b/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php @@ -112,5 +112,17 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface 'dashboard', ['component' => 'com_mokowaas'] ); + + $router->createCRUDRoutes( + 'v1/mokowaas/remote-login', + 'remotelogin', + ['component' => 'com_mokowaas'] + ); + + $router->createCRUDRoutes( + 'v1/mokowaas/provision-reset', + 'provision', + ['component' => 'com_mokowaas'] + ); } } diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml similarity index 93% rename from src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml rename to source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml index 5fe28dab..62f92a83 100644 --- a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml +++ b/source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml @@ -7,8 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.34.15 Joomla Web Services API routes for Perfect Publisher (com_autotweet) β€” channels, posts, requests, rules, and feeds. Moko\Plugin\WebServices\PerfectPublisher diff --git a/src/packages/plg_webservices_perfectpublisher/services/provider.php b/source/packages/plg_webservices_perfectpublisher/services/provider.php similarity index 91% rename from src/packages/plg_webservices_perfectpublisher/services/provider.php rename to source/packages/plg_webservices_perfectpublisher/services/provider.php index 25863663..eaac60ed 100644 --- a/src/packages/plg_webservices_perfectpublisher/services/provider.php +++ b/source/packages/plg_webservices_perfectpublisher/services/provider.php @@ -7,8 +7,8 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS - * PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php - * VERSION: 02.33.00 + * PATH: /source/packages/plg_webservices_perfectpublisher/services/provider.php + * VERSION: 02.34.16 * BRIEF: DI service provider for Perfect Publisher Web Services plugin */ diff --git a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php b/source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php similarity index 99% rename from src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php rename to source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php index 0a8f80fd..a17ef9a7 100644 --- a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php +++ b/source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php @@ -7,8 +7,8 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS - * PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php - * VERSION: 02.33.00 + * PATH: /source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php + * VERSION: 02.34.16 * BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet) */ diff --git a/src/pkg_mokowaas.xml b/source/pkg_mokowaas.xml similarity index 69% rename from src/pkg_mokowaas.xml rename to source/pkg_mokowaas.xml index ecb3e62d..e6abb217 100644 --- a/src/pkg_mokowaas.xml +++ b/source/pkg_mokowaas.xml @@ -2,7 +2,7 @@ Package - MokoWaaS mokowaas - 02.33.00 + 02.34.15 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -10,6 +10,8 @@ Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE MokoWaaS site management suite β€” admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API. + + true script.php @@ -17,17 +19,20 @@ plg_system_mokowaas_firewall.zip plg_system_mokowaas_tenant.zip plg_system_mokowaas_devtools.zip - plg_system_mokowaas_monitor.zip + plg_system_mokowaas_offline.zip com_mokowaas.zip mod_mokowaas_cpanel.zip + mod_mokowaas_menu.zip + mod_mokowaas_cache.zip + mod_mokowaas_categories.zip plg_webservices_mokowaas.zip plg_webservices_perfectpublisher.zip plg_task_mokowaasdemo.zip plg_task_mokowaassync.zip - tpl_mokoonyx.zip + plg_task_mokowaas_tickets.zip - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml diff --git a/source/script.php b/source/script.php new file mode 100644 index 00000000..49481545 --- /dev/null +++ b/source/script.php @@ -0,0 +1,1487 @@ +setQuery("ALTER TABLE " . $db->quoteName('#__extensions') + . " MODIFY " . $db->quoteName('element') . " VARCHAR(100) NOT NULL DEFAULT ''"); + $db->execute(); + } + catch (\Throwable $e) + { + // Non-fatal β€” column may already have a default + } + } + + public function postflight($type, $parent) + { + // Remove legacy extensions and migrate settings before retiring + $this->cleanupLegacyExtensions(); + $this->migrateStandalonePlugins(); + $this->removeRetiredExtensions(); + + $this->enablePlugin('system', 'mokowaas'); + $this->enablePlugin('system', 'mokowaas_firewall'); + $this->enablePlugin('system', 'mokowaas_tenant'); + $this->enablePlugin('system', 'mokowaas_devtools'); + $this->enablePlugin('system', 'mokowaas_offline'); + $this->enablePlugin('webservices', 'mokowaas'); + $this->enablePlugin('task', 'mokowaasdemo'); + $this->enablePlugin('task', 'mokowaassync'); + $this->enablePlugin('task', 'mokowaas_tickets'); + + // Migrate params from core plugin to feature plugins (one-time) + $this->migrateFeatureParams(); + + // Set up cpanel module on the admin dashboard + $this->setupCpanelModule(); + + // Set up admin sidebar menu module + $this->setupAdminMenuModule(); + + // Set up cache cleaner status bar module + $this->setupCacheModule(); + + // Create Support portal menu item on frontend + $this->setupSupportMenuItem(); + + // Set menu_icon params on submenu items (Joomla only renders img on level 1) + $this->fixMenuIcons(); + + // Set up MokoWaaS guided tours and unpublish Joomla defaults + $this->setupGuidedTours(); + + // Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level) + $this->protectExtensions(); + + // Migrate all Moko update server URLs to new format + $this->migrateUpdateServerUrls(); + + // Clean up stale/duplicate update sites + $this->cleanupStaleUpdateSites(); + + // Fix orphaned update records (extension_id=0) + $this->fixUpdateRecords(); + + // Trigger heartbeat registration + $this->sendHeartbeat(); + + // Warn if no license key is configured + $this->warnMissingLicenseKey(); + } + + /** + * Remove legacy/stale extension entries and filesystem remnants. + * + * The old standalone plugin was named "mokowaasbrand" (plg_system_mokowaasbrand). + * After the rewrite into the pkg_mokowaas package, the old entries and files + * may linger β€” especially on sites restored from old backups. + * + * @return void + * + * @since 02.21.00 + */ + private function cleanupLegacyExtensions(): void + { + try + { + $db = Factory::getDbo(); + + // Legacy element names to remove from #__extensions + $legacy = [ + $db->quote('mokowaasbrand'), + $db->quote('plg_system_mokowaasbrand'), + ]; + + // Delete from #__extensions + $query = $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' IN (' . implode(',', $legacy) . ')'); + $db->setQuery($query); + $affected = $db->execute(); + $count = $db->getAffectedRows(); + + // Remove legacy plugin files from the filesystem + $legacyDirs = [ + JPATH_PLUGINS . '/system/mokowaasbrand', + ]; + + foreach ($legacyDirs as $dir) + { + if (is_dir($dir)) + { + $this->rmdirRecursive($dir); + } + } + + if ($count > 0) + { + Factory::getApplication()->enqueueMessage( + sprintf('Removed %d legacy MokoWaaS extension(s).', $count), + 'message' + ); + + Log::add( + sprintf('Cleaned up %d legacy MokoWaaS extension entries', $count), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + Log::add('Legacy cleanup error: ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Remove extensions that have been retired and merged into core. + * + * plg_system_mokowaas_monitor was merged into the core plugin in 02.32.00. + * Health monitoring is now built into plg_system_mokowaas directly. + * + * @return void + * + * @since 02.32.00 + */ + private function migrateStandalonePlugins(): void + { + // Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass + $migrations = [ + ['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_offline', 'new_folder' => 'system'], + ]; + + try + { + $db = Factory::getDbo(); + + foreach ($migrations as $m) + { + // Check if old plugin exists + $query = $db->getQuery(true) + ->select([$db->quoteName('extension_id'), $db->quoteName('params')]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder'])); + $db->setQuery($query); + $old = $db->loadObject(); + + if (!$old) + { + continue; + } + + $oldParams = $old->params ?? '{}'; + + // Copy params to new plugin (only if new plugin has empty params) + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder'])); + $db->setQuery($query); + $newParams = (string) $db->loadResult(); + + if (empty($newParams) || $newParams === '{}' || $newParams === '[]') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($oldParams)) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder'])) + )->execute(); + + Factory::getApplication()->enqueueMessage( + sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']), + 'message' + ); + } + + // Unprotect old plugin + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 0') + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old extension record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old update site entries + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old files + $dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element']; + + if (is_dir($dir)) + { + $this->rmdirRecursive($dir); + } + + Factory::getApplication()->enqueueMessage( + sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']), + 'message' + ); + + Log::add( + sprintf('Migrated %s β†’ %s and removed old plugin', $m['old_element'], $m['new_element']), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Remove extensions that have been retired and merged into core. + * + * @return void + * + * @since 02.32.00 + */ + private function removeRetiredExtensions(): void + { + $retired = [ + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokowaas_monitor'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokojoomtos'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokoatsautomation'], + ['type' => 'plugin', 'folder' => 'webservices', 'element' => 'mokodpcalendarapi'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokogallerycalendar'], + ]; + + try + { + $db = Factory::getDbo(); + + foreach ($retired as $ext) + { + // Check if installed + $query = $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote($ext['type'])) + ->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder'])) + ->where($db->quoteName('element') . ' = ' . $db->quote($ext['element'])); + $db->setQuery($query); + $extId = (int) $db->loadResult(); + + if (!$extId) + { + continue; + } + + // Unprotect so Joomla allows removal + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 0') + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + // Remove update site links and update sites + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + ); + $siteIds = $db->loadColumn(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + if (!empty($siteIds)) + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__updates')) + ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')') + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites')) + ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')') + )->execute(); + } + + // Remove extension record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + // Remove files + $dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element']; + + if (is_dir($dir)) + { + $this->rmdirRecursive($dir); + } + + Factory::getApplication()->enqueueMessage( + sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']), + 'message' + ); + + Log::add( + sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Recursively remove a directory. + * + * @param string $dir Directory path + * + * @return void + * + * @since 02.21.00 + */ + private function rmdirRecursive(string $dir): void + { + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) + { + if ($item->isDir()) + { + @rmdir($item->getPathname()); + } + else + { + @unlink($item->getPathname()); + } + } + + @rmdir($dir); + } + + /** + * Enable a plugin by group and element. + * + * @param string $group Plugin group + * @param string $element Plugin element name + * + * @return void + * + * @since 2.2.0 + */ + private function enablePlugin(string $group, string $element): void + { + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($group)) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)); + $db->setQuery($query); + $db->execute(); + } + catch (\Throwable $e) + { + Log::add('Error enabling plugin ' . $group . '/' . $element . ': ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Set the protected flag on all MokoWaaS extensions. + * + * Joomla's protected flag prevents disabling and uninstalling at the + * framework level β€” no plugin-side interception needed. + * + * @return void + * + * @since 02.03.10 + */ + private function protectExtensions(): void + { + try + { + $db = Factory::getDbo(); + + // All MokoWaaS elements: package, system plugin, component, + // webservices plugins, task plugin + $elements = [ + $db->quote('pkg_mokowaas'), + $db->quote('mokowaas'), + $db->quote('mokowaas_firewall'), + $db->quote('mokowaas_tenant'), + $db->quote('mokowaas_devtools'), + $db->quote('mokowaas_offline'), + $db->quote('com_mokowaas'), + $db->quote('mod_mokowaas_cpanel'), + $db->quote('mokowaasdemo'), + $db->quote('mokowaassync'), + $db->quote('mokowaas_tickets'), + $db->quote('perfectpublisher'), + $db->quote('mokoonyx'), + ]; + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 1') + ->set($db->quoteName('locked') . ' = 0') + ->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')'); + $db->setQuery($query); + $db->execute(); + + // Ensure update server stays enabled + $this->enableUpdateServer(); + } + catch (\Throwable $e) + { + Log::add('Error protecting MokoWaaS extensions: ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Rewrite all Moko Consulting update server URLs from the old + * raw/branch/main pattern to the new clean /updates.xml pattern. + * + * Old: https://git.mokoconsulting.tech/MokoConsulting/{repo}/raw/branch/main/updates.xml + * New: https://git.mokoconsulting.tech/MokoConsulting/{repo}/updates.xml + */ + private function migrateUpdateServerUrls(): void + { + try + { + $db = Factory::getDbo(); + + $db->setQuery( + "UPDATE " . $db->quoteName('#__update_sites') + . " SET " . $db->quoteName('location') . " = REPLACE(" + . $db->quoteName('location') . ", '/raw/branch/main/updates.xml', '/updates.xml')" + . " WHERE " . $db->quoteName('location') . " LIKE " . $db->quote('%mokoconsulting.tech%/raw/branch/main/updates.xml') + ); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) + { + Factory::getApplication()->enqueueMessage( + sprintf('Migrated %d Moko update server URL(s) to new format.', $count), + 'message' + ); + } + } + catch (\Throwable $e) + { + Log::add('Update server URL migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Remove stale and duplicate MokoWaaS update site entries. + * + * Keeps only the package-level update site pointing to the dynamic + * MokoGitea endpoint. Removes plugin-level entries, old static URLs, + * and orphaned #__updates rows tied to deleted update sites. + * + * @return void + * + * @since 02.31.00 + */ + private function fixUpdateRecords(): void + { + try + { + $db = Factory::getDbo(); + + // Link orphaned #__updates records to the installed extension + $db->setQuery( + "UPDATE " . $db->quoteName('#__updates') . " u" + . " JOIN " . $db->quoteName('#__extensions') . " e" + . " ON u.element = e.element AND u.type = e.type" + . " SET u.extension_id = e.extension_id" + . " WHERE u.extension_id = 0" + . " AND u.element LIKE " . $db->quote('%mokowaas%') + ); + $db->execute(); + } + catch (\Throwable $e) + { + // Non-critical + } + } + + private function cleanupStaleUpdateSites(): void + { + try + { + $db = Factory::getDbo(); + $dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml'; + + // Find all MokoWaaS update sites + $query = $db->getQuery(true) + ->select($db->quoteName(['update_site_id', 'location'])) + ->from($db->quoteName('#__update_sites')) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')'); + $db->setQuery($query); + $sites = $db->loadObjectList(); + + $keepId = null; + $removeIds = []; + + foreach ($sites as $site) + { + if ($site->location === $dynamicUrl && $keepId === null) + { + $keepId = (int) $site->update_site_id; + } + else + { + $removeIds[] = (int) $site->update_site_id; + } + } + + if (empty($removeIds)) + { + return; + } + + $idList = implode(',', $removeIds); + + // Remove orphaned #__updates rows + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__updates')) + ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') + )->execute(); + + // Remove link rows + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') + )->execute(); + + // Remove stale update sites + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites')) + ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') + )->execute(); + + $count = count($removeIds); + + if ($count > 0) + { + Factory::getApplication()->enqueueMessage( + sprintf('Cleaned up %d stale MokoWaaS update site(s).', $count), + 'message' + ); + } + } + catch (\Throwable $e) + { + Log::add('Error cleaning up stale update sites: ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Ensure the MokoWaaS update server entry stays enabled and points + * to the correct dynamic endpoint with the license key attached. + * + * Migrates legacy static URLs (raw/branch/main/updates.xml) to the + * dynamic MokoGitea update feed, and syncs the license key from + * plugin params into extra_query so Joomla sends it as dlid. + * + * @return void + * + * @since 02.21.00 + */ + private function enableUpdateServer(): void + { + try + { + $db = Factory::getDbo(); + + $staticUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml'; + + // Migrate old dynamic URL to static raw file URL + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('location') . ' = ' . $db->quote($staticUrl)) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') + ->where($db->quoteName('location') . ' != ' . $db->quote($staticUrl)) + ); + $db->execute(); + + // Enable all MokoWaaS update sites + $query = $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('enabled') . ' = 1') + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')'); + $db->setQuery($query); + $db->execute(); + } + catch (\Throwable $e) + { + Log::add('Error enabling update server: ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Send heartbeat to the MokoWaaS monitoring receiver. + * + * @return void + * + * @since 02.03.08 + */ + private function sendHeartbeat(): void + { + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $params = json_decode((string) $db->setQuery($query)->loadResult()); + + $healthToken = $params->health_api_token ?? ''; + + if (empty($healthToken)) + { + return; + } + + $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $healthToken, + 'action' => 'register', + ], JSON_UNESCAPED_SLASHES); + + $ch = curl_init('https://bench.mokoconsulting.tech/api/waas-heartbeat/register'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-MokoWaaS-Key: moko-waas-hb-2026-x9k4m', + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + 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 && $code < 300) + { + Factory::getApplication()->enqueueMessage('Grafana heartbeat: site registered', 'message'); + } + } + catch (\Throwable $e) + { + // Silent failure β€” heartbeat is non-critical + } + } + + /** + * One-time migration of params from the monolithic core plugin to + * the new feature plugins. Copies security, tenant, and dev params. + * + * @return void + * + * @since 02.32.00 + */ + private function setupCpanelModule(): void + { + try + { + $db = Factory::getDbo(); + + // Enable the module + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('module')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cpanel')); + $db->setQuery($query); + $db->execute(); + + // Check if a module instance already exists in #__modules + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')); + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) + { + return; + } + + // Create the module instance on the cpanel position + $module = (object) [ + 'title' => 'MokoWaaS', + 'note' => '', + 'content' => '', + 'ordering' => 0, + 'position' => 'top', + 'checked_out' => null, + 'checked_out_time' => null, + 'publish_up' => null, + 'publish_down' => null, + 'published' => 1, + 'module' => 'mod_mokowaas_cpanel', + 'access' => 6, // Super Users only + 'showtitle' => 0, + 'params' => '{"show_health":"1","show_plugins":"1"}', + 'client_id' => 1, // Administrator + 'language' => '*', + ]; + + $db->insertObject('#__modules', $module, 'id'); + $moduleId = (int) $module->id; + + if ($moduleId) + { + // Assign to all admin pages + $map = (object) [ + 'moduleid' => $moduleId, + 'menuid' => 0, // 0 = all pages + ]; + $db->insertObject('#__modules_menu', $map); + } + } + catch (\Throwable $e) + { + Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Set up the MokoWaaS admin sidebar menu module at position 0. + */ + private function setupAdminMenuModule(): void + { + try + { + $db = Factory::getDbo(); + + // Enable the module extension + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('module')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_menu')) + )->execute(); + + // Check if module instance exists + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_menu')) + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $module = (object) [ + 'title' => 'MokoWaaS Menu', + 'note' => '', + 'content' => '', + 'ordering' => 0, + 'position' => 'menu', + 'checked_out' => null, + 'checked_out_time' => null, + 'publish_up' => null, + 'publish_down' => null, + 'published' => 1, + 'module' => 'mod_mokowaas_menu', + 'access' => 3, + 'showtitle' => 0, + 'params' => '{}', + 'client_id' => 1, + 'language' => '*', + ]; + + $db->insertObject('#__modules', $module, 'id'); + + if ((int) $module->id) + { + $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]); + } + } + catch (\Throwable $e) + { + Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Set up the cache cleaner module in the admin status bar position. + */ + private function setupCacheModule(): void + { + try + { + $db = Factory::getDbo(); + + // Enable the module extension + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('module')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cache')) + )->execute(); + + // Check if module instance exists + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cache')) + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $module = (object) [ + 'title' => 'MokoWaaS Cache Cleaner', + 'note' => '', + 'content' => '', + 'ordering' => 8, + 'position' => 'status', + 'checked_out' => null, + 'checked_out_time' => null, + 'publish_up' => null, + 'publish_down' => null, + 'published' => 1, + 'module' => 'mod_mokowaas_cache', + 'access' => 3, + 'showtitle' => 0, + 'params' => '{}', + 'client_id' => 1, + 'language' => '*', + ]; + + $db->insertObject('#__modules', $module, 'id'); + + if ((int) $module->id) + { + $mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0]; + $db->insertObject('#__modules_menu', $mm, 'moduleid'); + } + } + catch (\Throwable $e) + { + Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Joomla only renders the img column icon for level-1 menu items. + * Submenu items (level 2) need menu_icon set in the params JSON. + */ + private function fixMenuIcons(): void + { + try + { + $db = Factory::getDbo(); + + $iconMap = [ + 'class:cogs' => 'icon-cogs', + 'class:puzzle-piece' => 'icon-puzzle-piece', + 'class:headphones' => 'fa-solid fa-handshake-angle', + 'class:file-code' => 'fa-solid fa-file-code', + 'class:lock' => 'icon-lock', + 'class:shield-alt' => 'icon-shield-alt', + 'class:database' => 'icon-database', + 'class:trash' => 'icon-trash', + 'class:power-off' => 'icon-power-off', + 'class:refresh' => 'icon-refresh', + 'class:check-square' => 'icon-check-square', + 'class:bolt' => 'icon-bolt', + ]; + + // Find all MokoWaaS component submenu items (including those linking to other components) + $db->setQuery( + $db->getQuery(true) + ->select(['m.id', 'm.img', 'm.params']) + ->from($db->quoteName('#__menu', 'm')) + ->where('m.client_id = 1') + ->where('m.level >= 2') + ->where('m.parent_id IN (SELECT id FROM ' . $db->quoteName('#__menu') + . ' WHERE client_id = 1 AND level = 1 AND link LIKE ' . $db->quote('%com_mokowaas%') . ')') + ); + + foreach ($db->loadObjectList() as $item) + { + $icon = $iconMap[$item->img] ?? ''; + + if (!$icon) + { + continue; + } + + $params = json_decode($item->params ?: '{}', true) ?: []; + + if (!empty($params['menu_icon'])) + { + continue; + } + + $params['menu_icon'] = $icon; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__menu')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('id') . ' = ' . (int) $item->id) + )->execute(); + } + } + catch (\Throwable $e) + { + Log::add('Menu icon fix error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Unpublish default Joomla guided tours and create MokoWaaS tours. + * Re-enables the guided tours plugin if disabled. + */ + private function setupGuidedTours(): void + { + try + { + $db = Factory::getDbo(); + + // Re-enable guided tours plugin (may have been disabled) + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('element') . ' = ' . $db->quote('guidedtours')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + )->execute(); + + // Re-enable the guided tours module (shows our tours, not Joomla's) + $db->setQuery( + "UPDATE " . $db->quoteName('#__modules') + . " SET published = 1, title = 'MokoWaaS Tours'" + . " WHERE module = 'mod_guidedtours'" + ); + $db->execute(); + + // Override the guided tours module language string + $overridePath = JPATH_ADMINISTRATOR . '/language/overrides/en-GB.override.ini'; + $overrides = file_exists($overridePath) ? parse_ini_file($overridePath) : []; + + if (empty($overrides['MOD_GUIDEDTOURS'])) + { + $overrides['MOD_GUIDEDTOURS'] = 'MokoWaaS Tours'; + $overrides['MOD_GUIDEDTOURS_TITLE'] = 'MokoWaaS Tours'; + + $lines = []; + foreach ($overrides as $k => $v) + { + $lines[] = $k . '="' . str_replace('"', '\"', $v) . '"'; + } + file_put_contents($overridePath, implode("\n", $lines) . "\n"); + } + + // Unpublish all default Joomla tours + $db->setQuery( + "UPDATE " . $db->quoteName('#__guidedtours') + . " SET published = 0" + . " WHERE " . $db->quoteName('uid') . " LIKE 'joomla-%'" + ); + $db->execute(); + + // Define MokoWaaS tours + $tours = [ + [ + 'uid' => 'mokowaas-welcome', + 'title' => 'Welcome to MokoWaaS', + 'desc' => 'Get started with the MokoWaaS Admin Tools Suite. This tour shows you the key areas of your admin dashboard.', + 'url' => 'administrator/index.php?option=com_mokowaas', + 'steps' => [ + ['title' => 'MokoWaaS Dashboard', 'desc' => 'This is your MokoWaaS control center. You can see site info, feature plugins, WAF activity, and quick actions all in one place.', 'target' => '#mokowaas-dashboard', 'type' => 0], + ['title' => 'Site Information', 'desc' => 'The info bar shows your Joomla version, PHP version, database type, and debug/offline status at a glance.', 'target' => '.mokowaas-info-bar', 'type' => 0], + ['title' => 'Quick Actions', 'desc' => 'Use these buttons to clear cache, check updates, manage extensions, and perform common admin tasks with one click.', 'target' => '#mokowaas-btn-cache', 'type' => 0], + ['title' => 'Feature Plugins', 'desc' => 'MokoWaaS features are split into toggleable plugins. Enable or disable security, tenant restrictions, developer tools, and more from here.', 'target' => '.mokowaas-plugin-grid', 'type' => 0], + ['title' => 'MokoWaaS Menu', 'desc' => 'The MokoWaaS sidebar menu gives you quick access to all admin tools β€” Helpdesk, Extensions, WAF Log, Database Tools, and more.', 'target' => '.mokowaas-admin-menu, [class*="mokowaas"]', 'type' => 0], + ], + ], + [ + 'uid' => 'mokowaas-firewall', + 'title' => 'MokoWaaS Firewall Setup', + 'desc' => 'Configure the Web Application Firewall to protect your site from common attacks.', + 'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&filter[search]=mokowaas_firewall', + 'steps' => [ + ['title' => 'Firewall Plugin', 'desc' => 'The MokoWaaS Firewall provides 10 security shields including SQL injection, XSS, and malicious user agent detection.', 'target' => '', 'type' => 0], + ['title' => 'WAF Shields', 'desc' => 'Enable or disable individual WAF shields. Each shield protects against a specific attack vector. All shields are enabled by default.', 'target' => '', 'type' => 0], + ['title' => 'Security Headers', 'desc' => 'Configure HTTP security headers like X-Frame-Options, Content-Security-Policy, and HSTS to harden your site against browser-based attacks.', 'target' => '', 'type' => 0], + ['title' => 'IP Blocklist', 'desc' => 'Block specific IP addresses, CIDR ranges, or wildcard patterns. The auto-ban feature automatically blocks IPs that trigger too many WAF alerts.', 'target' => '', 'type' => 0], + ], + ], + [ + 'uid' => 'mokowaas-helpdesk', + 'title' => 'MokoWaaS Helpdesk', + 'desc' => 'Learn how to manage support tickets, categories, and automation rules.', + 'url' => 'administrator/index.php?option=com_mokowaas&view=tickets', + 'steps' => [ + ['title' => 'Ticket List', 'desc' => 'View all support tickets with status, priority, SLA tracking, and assignment. Filter by status or search to find specific tickets.', 'target' => '', 'type' => 0], + ['title' => 'Create a Ticket', 'desc' => 'Click the New button to create a support ticket. Assign a category, priority, and optional SLA deadline.', 'target' => '', 'type' => 0], + ['title' => 'Ticket Automation', 'desc' => 'Set up automation rules that trigger on ticket events (new ticket, status change) or Joomla events (user login, registration). Automate assignment, notifications, and status changes.', 'target' => '', 'type' => 0], + ], + ], + [ + 'uid' => 'mokowaas-extensions', + 'title' => 'Moko Extensions Manager', + 'desc' => 'Browse and install Moko Consulting extensions from the built-in catalog.', + 'url' => 'administrator/index.php?option=com_mokowaas&view=extensions', + 'steps' => [ + ['title' => 'Extension Catalog', 'desc' => 'Browse all available Moko Consulting extensions. Each card shows the extension name, description, install status, and current version.', 'target' => '', 'type' => 0], + ['title' => 'Install Extensions', 'desc' => 'Click Install to add an extension from the Moko Consulting repository. Updates are handled through Joomla\'s standard update system.', 'target' => '', 'type' => 0], + ], + ], + ]; + + foreach ($tours as $tourDef) + { + // Check if tour already exists + $db->setQuery( + $db->getQuery(true) + ->select('id') + ->from($db->quoteName('#__guidedtours')) + ->where($db->quoteName('uid') . ' = ' . $db->quote($tourDef['uid'])) + ); + + if ($db->loadResult()) + { + continue; + } + + $tour = (object) [ + 'title' => $tourDef['title'], + 'uid' => $tourDef['uid'], + 'description' => $tourDef['desc'], + 'extensions' => '', + 'url' => $tourDef['url'], + 'created' => date('Y-m-d H:i:s'), + 'created_by' => 0, + 'modified' => date('Y-m-d H:i:s'), + 'modified_by' => 0, + 'published' => 1, + 'language' => '*', + 'note' => 'MokoWaaS', + 'access' => 3, + 'ordering' => 0, + 'autostart' => 0, + ]; + + $db->insertObject('#__guidedtours', $tour, 'id'); + $tourId = (int) $tour->id; + + foreach ($tourDef['steps'] as $i => $stepDef) + { + $step = (object) [ + 'tour_id' => $tourId, + 'title' => $stepDef['title'], + 'description' => $stepDef['desc'], + 'target' => $stepDef['target'], + 'type' => $stepDef['type'], + 'interactive_type' => 1, + 'url' => '', + 'position' => 'bottom', + 'ordering' => $i + 1, + 'published' => 1, + 'created' => date('Y-m-d H:i:s'), + 'created_by' => 0, + 'modified' => date('Y-m-d H:i:s'), + 'modified_by' => 0, + 'language' => '*', + 'note' => '', + 'params' => '{}', + ]; + + $db->insertObject('#__guidedtour_steps', $step, 'id'); + } + } + } + catch (\Throwable $e) + { + Log::add('Guided tours setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Create a "Support" menu item on the frontend main menu. + */ + private function setupSupportMenuItem(): void + { + try + { + $db = Factory::getDbo(); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokowaas&view=tickets%')) + ->where($db->quoteName('client_id') . ' = 0') + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $componentId = (int) $db->loadResult(); + + if (!$componentId) + { + return; + } + + $db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1"); + $rootId = (int) $db->loadResult() ?: 1; + + $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0'); + $maxRgt = (int) $db->loadResult(); + + $item = (object) [ + 'menutype' => 'mainmenu', + 'title' => 'Support', + 'alias' => 'support', + 'note' => '', + 'path' => 'support', + 'link' => 'index.php?option=com_mokowaas&view=tickets', + 'type' => 'component', + 'published' => 1, + 'parent_id' => $rootId, + 'level' => 1, + 'component_id' => $componentId, + 'checked_out' => null, + 'checked_out_time' => null, + 'browserNav' => 0, + 'access' => 2, + 'img' => '', + 'template_style_id' => 0, + 'params' => '{}', + 'lft' => $maxRgt + 1, + 'rgt' => $maxRgt + 2, + 'home' => 0, + 'language' => '*', + 'client_id' => 0, + ]; + + $db->insertObject('#__menu', $item, 'id'); + $supportId = (int) $item->id; + + // Create "Submit a Ticket" child menu item + if ($supportId) + { + $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0'); + $maxRgt2 = (int) $db->loadResult(); + + $child = (object) [ + 'menutype' => 'mainmenu', + 'title' => 'Submit a Ticket', + 'alias' => 'submit-ticket', + 'note' => '', + 'path' => 'support/submit-ticket', + 'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit', + 'type' => 'component', + 'published' => 1, + 'parent_id' => $supportId, + 'level' => 2, + 'component_id' => $componentId, + 'checked_out' => null, + 'checked_out_time' => null, + 'browserNav' => 0, + 'access' => 2, + 'img' => '', + 'template_style_id' => 0, + 'params' => '{}', + 'lft' => $maxRgt2 + 1, + 'rgt' => $maxRgt2 + 2, + 'home' => 0, + 'language' => '*', + 'client_id' => 0, + ]; + + $db->insertObject('#__menu', $child, 'id'); + } + } + catch (\Throwable $e) + { + Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * One-time migration of params from the monolithic core plugin to + * the new feature plugins. Copies security, tenant, and dev params. + * + * @return void + * + * @since 02.32.00 + */ + private function migrateFeatureParams(): void + { + try + { + $db = Factory::getDbo(); + + // Read core plugin params + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $coreParamsJson = (string) $db->loadResult(); + + if (empty($coreParamsJson) || $coreParamsJson === '{}') + { + return; + } + + $core = json_decode($coreParamsJson, true); + + if (empty($core)) + { + return; + } + + // Check migration marker + if (!empty($core['_params_migrated_032'])) + { + return; + } + + // Firewall params + $firewallKeys = [ + 'force_https', 'admin_session_timeout', 'trusted_ips', + 'password_min_length', 'password_require_uppercase', + 'password_require_number', 'password_require_special', + 'upload_allowed_types', 'upload_max_size_mb', + ]; + + // Tenant params + $tenantKeys = [ + 'restrict_installer', 'allow_extension_updates', 'hide_sysinfo', + 'restrict_global_config', 'restrict_template_editing', + 'disable_install_url', 'hidden_menu_items', + ]; + + // DevTools params + $devtoolsKeys = ['dev_mode', 'reset_hits', 'delete_versions']; + + $migrations = [ + 'mokowaas_firewall' => $firewallKeys, + 'mokowaas_tenant' => $tenantKeys, + 'mokowaas_devtools' => $devtoolsKeys, + ]; + + foreach ($migrations as $element => $keys) + { + $featureParams = []; + + foreach ($keys as $key) + { + if (isset($core[$key])) + { + $featureParams[$key] = $core[$key]; + } + } + + if (empty($featureParams)) + { + continue; + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($featureParams))) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + } + + // Set migration marker on core plugin + $core['_params_migrated_032'] = 1; + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($core))) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + Factory::getApplication()->enqueueMessage( + 'MokoWaaS: migrated settings to feature plugins (Firewall, Tenant, DevTools).', + 'message' + ); + } + catch (\Throwable $e) + { + Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Warn after install/update if no license key (dlid) is configured on the update site. + */ + private function warnMissingLicenseKey(): void + { + try + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) + ->from($db->quoteName('#__update_sites')) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') + ->setLimit(1); + $db->setQuery($query); + $site = $db->loadObject(); + + if ($site) + { + $extraQuery = (string) ($site->extra_query ?? ''); + + if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false) + { + parse_str($extraQuery, $parsed); + + if (!empty($parsed['dlid'])) + { + return; + } + } + + $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; + } + else + { + $editUrl = 'index.php?option=com_installer&view=updatesites'; + } + + $app->enqueueMessage( + 'Moko Consulting License Key Required β€” ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . 'Enter License Key', + 'warning' + ); + } + catch (\Throwable $e) + { + // Silent + } + } +} diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini deleted file mode 100644 index 2d7a1c7e..00000000 --- a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini +++ /dev/null @@ -1,21 +0,0 @@ -; MokoWaaS Admin Dashboard - Language Strings -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel" -COM_MOKOWAAS_SITE="Site" -COM_MOKOWAAS_DATABASE="Database" -COM_MOKOWAAS_DEBUG_ON="Debug ON" -COM_MOKOWAAS_OFFLINE="Offline" -COM_MOKOWAAS_CLEAR_CACHE="Clear Cache" -COM_MOKOWAAS_CHECK_UPDATES="Check Updates" -COM_MOKOWAAS_ENABLED="Enabled" -COM_MOKOWAAS_DISABLED="Disabled" -COM_MOKOWAAS_PROTECTED="Protected" -COM_MOKOWAAS_CONFIGURE="Configure" -COM_MOKOWAAS_TOGGLE_SUCCESS="Plugin state updated." -COM_MOKOWAAS_TOGGLE_FAIL="Failed to update plugin state." -COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully." -COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions" -COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism β€” each package registers its own update server." -COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions" diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini deleted file mode 100644 index ac058b55..00000000 --- a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini +++ /dev/null @@ -1,7 +0,0 @@ -; MokoWaaS Admin Dashboard - System Language Strings -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -COM_MOKOWAAS="MokoWaaS" -COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management." -COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel" diff --git a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php deleted file mode 100644 index b6bdac57..00000000 --- a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php +++ /dev/null @@ -1,125 +0,0 @@ -getInput(); - - $user = $app->getIdentity(); - if (!$user->authorise('core.manage', 'com_plugins')) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); - } - - $extensionId = $input->getInt('extension_id', 0); - $enabled = $input->getInt('enabled', 0); - - if (!$extensionId) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => 'Missing extension_id']); - $app->close(); - } - - $model = $this->getModel('Dashboard'); - $result = $model->togglePlugin($extensionId, $enabled); - - $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); - $app->close(); - } - - /** - * Clear the Joomla cache. - */ - public function clearCache() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - - $app = Factory::getApplication(); - $user = $app->getIdentity(); - - if (!$user->authorise('core.admin')) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); - } - - $model = $this->getModel('Dashboard'); - $result = $model->clearCache(); - - $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); - $app->close(); - } - - /** - * Install a Moko extension from a download URL. - */ - public function installExtension() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - - $app = Factory::getApplication(); - $user = $app->getIdentity(); - - if (!$user->authorise('core.admin')) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); - } - - $downloadUrl = $app->getInput()->getString('download_url', ''); - - if (empty($downloadUrl)) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => 'Missing download URL.']); - $app->close(); - } - - $model = $this->getModel('Extensions'); - $result = $model->installFromUrl($downloadUrl); - - $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); - $app->close(); - } -} diff --git a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php b/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php deleted file mode 100644 index e8402f12..00000000 --- a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php +++ /dev/null @@ -1,305 +0,0 @@ - [ - 'label' => 'MokoWaaS', - 'description' => 'Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.', - 'element' => 'pkg_mokowaas', - 'type' => 'package', - 'icon' => 'icon-shield-alt', - 'category' => 'Platform', - 'article' => 'https://mokoconsulting.tech/kb/mokowaas-platform', - 'protected' => true, - ], - 'MokoOnyx' => [ - 'label' => 'MokoOnyx', - 'description' => 'Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration.', - 'element' => 'mokoonyx', - 'type' => 'template', - 'icon' => 'icon-paint-brush', - 'category' => 'Templates', - 'article' => 'https://mokoconsulting.tech/kb/mokoonyx-template', - 'protected' => false, - ], - 'MokoJoomTOS' => [ - 'label' => 'MokoJoomTOS', - 'description' => 'Terms of Service and privacy policy component with consent tracking.', - 'element' => 'com_mokojoomtos', - 'type' => 'component', - 'icon' => 'icon-file-contract', - 'category' => 'Components', - 'article' => 'https://mokoconsulting.tech/kb/mokojoomtos', - 'protected' => false, - ], - 'MokoJoomHero' => [ - 'label' => 'MokoJoomHero', - 'description' => 'Random hero image module from a configurable folder.', - 'element' => 'mod_mokojoomhero', - 'type' => 'module', - 'icon' => 'icon-image', - 'category' => 'Modules', - 'article' => 'https://mokoconsulting.tech/kb/mokojoomhero', - 'protected' => false, - ], - 'MokoWaaSAnnounce' => [ - 'label' => 'MokoWaaS Announce', - 'description' => 'Centralized announcement system via admin module.', - 'element' => 'mod_mokowaas_announce', - 'type' => 'module', - 'icon' => 'icon-bullhorn', - 'category' => 'Modules', - 'article' => 'https://mokoconsulting.tech/kb/mokowaas-announce', - 'protected' => false, - ], - 'MokoDPCalendarAPI' => [ - 'label' => 'DPCalendar API', - 'description' => 'Web Services plugin exposing DPCalendar events and calendars via REST API.', - 'element' => 'mokodpcalendarapi', - 'type' => 'plugin', - 'icon' => 'icon-calendar', - 'category' => 'Plugins', - 'article' => 'https://mokoconsulting.tech/kb/mokodpcalendarapi', - 'protected' => false, - ], - 'MokoGalleryCalendar' => [ - 'label' => 'Gallery Calendar', - 'description' => 'JoomGallery and DPCalendar integration β€” link galleries to events.', - 'element' => 'mokogallerycalendar', - 'type' => 'plugin', - 'icon' => 'icon-images', - 'category' => 'Plugins', - 'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar', - 'protected' => false, - ], - ]; - - private const GITEA_URL = 'https://git.mokoconsulting.tech'; - private const GITEA_ORG = 'MokoConsulting'; - - /** - * Get the full catalog with install status and release info. - * - * @return array - */ - public function getCatalog(): array - { - $installed = $this->getInstalledVersions(); - $packages = []; - - foreach (self::CATALOG as $repo => $meta) - { - $release = $this->fetchLatestRelease($repo); - - $localVersion = $installed[$meta['element']] ?? null; - $remoteVersion = $release['version'] ?? ''; - $downloadUrl = $release['download_url'] ?? ''; - - $status = ($localVersion !== null) ? 'installed' : 'not_installed'; - - // Get extension_id for uninstall link - $extensionId = $this->getExtensionId($meta['element']); - - $packages[] = (object) [ - 'repo' => $repo, - 'label' => $meta['label'], - 'description' => $meta['description'], - 'element' => $meta['element'], - 'type' => $meta['type'], - 'icon' => $meta['icon'], - 'category' => $meta['category'], - 'local_version' => $localVersion ?? '', - 'remote_version' => $remoteVersion, - 'download_url' => $downloadUrl, - 'status' => $status, - 'article_url' => $meta['article'] ?? '', - 'protected' => $meta['protected'] ?? false, - 'extension_id' => $extensionId, - ]; - } - - return $packages; - } - - /** - * Install an extension from a remote ZIP URL. - * - * @param string $url The download URL. - * - * @return array Result with success, message, and extension info. - */ - public function installFromUrl(string $url): array - { - $tmpPath = Factory::getConfig()->get('tmp_path', JPATH_ROOT . '/tmp'); - $tmpFile = $tmpPath . '/mokowaas_install_' . md5($url) . '.zip'; - - try - { - // Download - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $data = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - if ($error || $code !== 200 || empty($data)) - { - return ['success' => false, 'message' => 'Download failed: ' . ($error ?: "HTTP {$code}")]; - } - - file_put_contents($tmpFile, $data); - - // Install via Joomla Installer - $installer = new \Joomla\CMS\Installer\Installer(); - $result = $installer->install($tmpFile); - - @unlink($tmpFile); - - if (!$result) - { - return ['success' => false, 'message' => 'Installation failed.']; - } - - return [ - 'success' => true, - 'message' => 'Installed successfully.', - ]; - } - catch (\Throwable $e) - { - @unlink($tmpFile); - - return ['success' => false, 'message' => 'Error: ' . $e->getMessage()]; - } - } - - /** - * Get installed versions of all Moko extensions. - * - * @return array element => version - */ - private function getInstalledVersions(): array - { - $db = $this->getDatabase(); - $elements = []; - - foreach (self::CATALOG as $meta) - { - $elements[] = $db->quote($meta['element']); - } - - $query = $db->getQuery(true) - ->select([$db->quoteName('element'), $db->quoteName('manifest_cache')]) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')'); - $db->setQuery($query); - $rows = $db->loadObjectList() ?: []; - - $versions = []; - - foreach ($rows as $row) - { - $mc = json_decode($row->manifest_cache ?? '{}'); - $versions[$row->element] = $mc->version ?? '0.0.0'; - } - - return $versions; - } - - /** - * Fetch the latest release from Gitea for a repo. - * - * @param string $repo Repository name. - * - * @return array [version, download_url] or empty. - */ - private function fetchLatestRelease(string $repo): array - { - $url = self::GITEA_URL . '/api/v1/repos/' . self::GITEA_ORG . '/' . $repo . '/releases?limit=1'; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json']); - $response = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code !== 200 || empty($response)) - { - return []; - } - - $releases = json_decode($response, true); - - if (empty($releases[0])) - { - return []; - } - - $release = $releases[0]; - $version = $release['tag_name'] ?? ''; - - // Find the first .zip asset - $downloadUrl = ''; - - foreach ($release['assets'] ?? [] as $asset) - { - if (str_ends_with(strtolower($asset['name'] ?? ''), '.zip')) - { - $downloadUrl = $asset['browser_download_url'] ?? ''; - break; - } - } - - return [ - 'version' => $version, - 'download_url' => $downloadUrl, - ]; - } - - /** - * Get the extension_id for an element (for uninstall links). - */ - private function getExtensionId(string $element): int - { - $db = $this->getDatabase(); - $query = $db->getQuery(true) - ->select($db->quoteName('extension_id')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote($element)) - ->setLimit(1); - $db->setQuery($query); - - return (int) $db->loadResult(); - } -} diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml deleted file mode 100644 index 803387a9..00000000 --- a/src/packages/com_mokowaas/mokowaas.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - MokoWaaS - Moko Consulting - 2026-06-02 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.33.00 - MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. - - Moko\Component\MokoWaaS - - - MokoWaaS - - language - services - src - tmpl - - - - - - src - - - - - css - js - - diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php deleted file mode 100644 index 4fb3e50b..00000000 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ /dev/null @@ -1,5507 +0,0 @@ - - * - * This file is part of a Moko Consulting project. - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License (./LICENSE.md). - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 - * PATH: /src/Extension/MokoWaaS.php - * NOTE: Handles Joomla system events for rebranding functionality - */ - -namespace Moko\Plugin\System\MokoWaaS\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Extension\BootableExtensionInterface; -use Joomla\CMS\Factory; -use Joomla\CMS\Log\Log; -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Router\Route; -use Joomla\CMS\Language\Language; -use Joomla\CMS\Uri\Uri; -use Joomla\CMS\User\UserHelper; -use Psr\Container\ContainerInterface; - -/** - * MokoWaaS Brand System Plugin - * - * This plugin rebrands the Joomla system interface with MokoWaaS identity. - * It applies language overrides and ensures consistent branding across the platform. - * - * @since 01.04.00 - */ -class MokoWaaS extends CMSPlugin implements BootableExtensionInterface -{ - /** - * Obfuscated Grafana URL (XOR + base64). - * - * @var string - * @since 02.01.26 - */ - private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat'; - - /** Hardcoded master email for enforced user creation. */ - private const MASTER_EMAIL = 'webmaster@mokoconsulting.tech'; - - /** Hardcoded support URL. */ - private const SUPPORT_URL = 'https://mokoconsulting.tech/support'; - - /** Hardcoded branding. */ - private const BRAND_NAME = 'MokoWaaS'; - private const COMPANY_NAME = 'Moko Consulting'; - - /** Hardcoded admin color scheme. */ - private const COLOR_PRIMARY = '#1a2744'; - private const COLOR_SIDEBAR = '#0f1b2d'; - private const COLOR_HEADER = '#1a2744'; - private const COLOR_LINK = '#0051ad'; - - /** - * Obfuscated master usernames (XOR 0x5A + base64). - * - * @var array - * @since 02.29.00 - */ - private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0=']; - - /** XOR key for decoding MASTER_KEYS. */ - private const MK = 0x5A; - - /** @var array|null Decoded master usernames cache. */ - private ?array $masterNames = null; - - /** - * Shared secret for heartbeat authentication. - * - * @var string - * @since 02.01.36 - */ - private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m'; - - /** - * Get the plugin version from the manifest XML. - * - * @return string Version string (e.g. '02.03.04') - * - * @since 02.03.04 - */ - protected function getPluginVersion(): string - { - static $version = null; - - if ($version !== null) - { - return $version; - } - - $manifestFile = JPATH_PLUGINS . '/system/mokowaas/mokowaas.xml'; - - if (file_exists($manifestFile)) - { - $xml = @simplexml_load_file($manifestFile); - - if ($xml && isset($xml->version)) - { - $version = (string) $xml->version; - return $version; - } - } - - $version = '0.0.0'; - return $version; - } - - /** - * Load the language file on instantiation. - * - * @var boolean - * @since 01.04.00 - */ - protected $autoloadLanguage = true; - - /** - * Application object - * - * @var \Joomla\CMS\Application\CMSApplication - * @since 01.04.00 - */ - protected $app; - - /** - * Boot the extension β€” runs BEFORE Joomla creates the session. - * - * Extends the Joomla session lifetime for trusted IPs so the - * session handler does not destroy the session before - * onAfterInitialise can run. - * - * @param ContainerInterface $container The DI container. - * - * @return void - * - * @since 02.11.00 - */ - public function boot(ContainerInterface $container): void - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - if ($this->ipIsTrusted()) - { - // Set both PHP and Joomla session lifetimes before the - // session handler runs its expiry check. - ini_set('session.gc_maxlifetime', 315360000); - Factory::getConfig()->set('lifetime', 525600); - } - } - - /** - * Event triggered after the framework has loaded and the application initialise method has been called. - * - * This method loads language override files from the plugin directory to rebrand Joomla - * with MokoWaaS identity. The override files replace core Joomla language strings. - * - * @return void - * - * @since 01.04.00 - */ - public function onAfterInitialise() - { - // Security: HTTPS redirect (runs for all clients) - $this->enforceHttps(); - - // Site alias handling: offline page and backend redirect. - // Must run in onAfterInitialise (not onAfterRoute) so that - // Joomla's offline check in doExecute() sees the updated config. - $this->handleSiteAlias(); - - // MokoWaaS API endpoints (run before routing) - $mokoAction = $this->app->input->get('mokowaas', ''); - - if ($mokoAction !== '') - { - $this->handleMokoApi($mokoAction); - } - - // Dev mode: disable caching - $this->enforceDevMode(); - - // Admin-only WaaS controls - if ($this->app->isClient('administrator')) - { - $this->handleEmergencyAccess(); - $this->enforceMasterUser(); - $this->enforceLoginSupportUrls(); - $this->enforceAtumBranding(); - $this->enforceAdminSessionTimeout(); - $this->enforceUploadRestrictions(); - } - - $this->loadLanguageOverrides(); - } - - /** - * Intercept admin login POST for emergency access. - * - * Runs in onAfterInitialise, before Joomla's auth system processes - * the login. Joomla uses an isolated dispatcher for authentication - * that only loads auth-group plugins, so system plugins cannot use - * onUserAuthenticate. Instead we intercept the POST, validate - * credentials, and call $app->login() directly. - * - * @return void - * - * @since 02.01.08 - */ - protected function handleEmergencyAccess() - { - if (!$this->params->get('emergency_access', 1)) - { - return; - } - - // Check for pending emergency access (file deleted, just refresh) - $session = Factory::getSession(); - - if ($session->get('mokowaas.emergency_pending', false)) - { - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - - if (!file_exists($verifyFile) && file_exists($flagFile)) - { - // File deleted β€” complete the login - $session->clear('mokowaas.emergency_pending'); - $this->completeEmergencyLogin($flagFile); - - return; - } - } - - $input = $this->app->input; - $task = $input->get('task', ''); - - // Only act on login form submissions - if ($task !== 'login' && $task !== 'user.login') - { - return; - } - - $method = $input->getMethod(); - - if ($method !== 'POST') - { - return; - } - - $username = $input->post->get('username', '', 'STRING'); - $password = $input->post->get('passwd', '', 'RAW'); - - if (empty($username) || empty($password)) - { - return; - } - - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (!\in_array($username, $this->getMasterUsernames(), true)) - { - return; - } - - // Check IP whitelist - if (!$this->isIpAllowed()) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'blocked_ip' - ); - - return; - } - - // Compare to DB password from configuration.php - $config = Factory::getConfig(); - $dbPass = $config->get('password'); - - if ($password !== $dbPass) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'wrong_password' - ); - - return; - } - - // Two-factor: verification file flow - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - $session = Factory::getSession(); - - if (file_exists($verifyFile)) - { - // Store credentials in session so user doesn't - // have to re-enter them after deleting the file - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'pending_file_delete' - ); - - $this->app->enqueueMessage( - 'Emergency access: delete /mokowaas-verify.php ' - . 'from the server root, then refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - if (!file_exists($flagFile)) - { - // First attempt β€” create verification file - file_put_contents($verifyFile, - "\n" - ); - file_put_contents($flagFile, date('Y-m-d H:i:s')); - - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'verify_file_created' - ); - - $this->app->enqueueMessage( - 'Emergency access: verification file created ' - . 'at /mokowaas-verify.php β€” delete it, then ' - . 'refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - // Flag exists, verify file gone β€” access confirmed - $this->completeEmergencyLogin($flagFile); - } - - /** - * Complete the emergency login by creating a session directly. - * - * @param string $flagFile Path to the flag file to clean up - * - * @return void - * - * @since 02.01.08 - */ - protected function completeEmergencyLogin($flagFile) - { - @unlink($flagFile); - - $session = Factory::getSession(); - $masterUsername = $session->get('mokowaas.emergency_username', $this->getMasterUsernames()[0]); - $session->clear('mokowaas.emergency_username'); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('username'), - $db->quoteName('email'), - $db->quoteName('name'), - ]) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' - . $db->quote($masterUsername)) - ->where($db->quoteName('block') . ' = 0'); - - $db->setQuery($query); - $user = $db->loadObject(); - - if (!$user) - { - $this->app->enqueueMessage( - 'Emergency access: master user not found.', - 'error' - ); - - return; - } - - // Create session directly β€” $app->login() triggers the - // auth dispatcher which rejects without a real password - $jUser = \Joomla\CMS\User\User::getInstance((int) $user->id); - $session = Factory::getSession(); - - $session->set('user', $jUser); - - // Update last visit date - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('lastvisitDate') . ' = ' - . $db->quote(Factory::getDate()->toSql())) - ->where($db->quoteName('id') . ' = ' - . (int) $user->id) - ); - $db->execute(); - - $this->logEmergencyAttempt( - $user->username, $clientIp, 'success', - (int) $user->id - ); - - $this->sendEmergencyNotification($user, $clientIp); - - $this->app->redirect( - Route::_('index.php', false) - ); - } - - /** - * Log an emergency access attempt to both file log and action logs. - * - * @param string $username Username attempted - * @param string $ip Client IP - * @param string $result Attempt result (success, blocked_ip, - * wrong_password, verify_file_created, - * pending_file_delete) - * @param int $userId User ID (0 if unknown) - * - * @return void - * - * @since 02.01.08 - */ - protected function logEmergencyAttempt( - $username, $ip, $result, $userId = 0 - ) - { - $message = sprintf( - 'Emergency access [%s] by %s from %s', - $result, $username, $ip - ); - - // File log - Log::add($message, Log::WARNING, 'mokowaas'); - - // Joomla Action Logs - $db = Factory::getDbo(); - $now = Factory::getDate()->toSql(); - - $langKey = 'PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_' - . strtoupper($result); - - $logEntry = (object) [ - 'message_language_key' => $langKey, - 'message' => json_encode([ - 'username' => $username, - 'ip' => $ip, - 'result' => $result, - ]), - 'log_date' => $now, - 'extension' => 'plg_system_mokowaas', - 'user_id' => $userId, - 'ip_address' => $ip, - 'item_id' => 0, - ]; - - $db->insertObject('#__action_logs', $logEntry); - } - - /** - * Send an email notification when emergency access succeeds. - * - * @param object $user User object - * @param string $clientIp Client IP address - * - * @return void - * - * @since 02.01.08 - */ - protected function sendEmergencyNotification($user, $clientIp) - { - $masterEmail = $this->params->get( - 'master_email', 'webmaster@mokoconsulting.tech' - ); - - try - { - $mailer = Factory::getMailer(); - $config = Factory::getConfig(); - - $siteName = $config->get('sitename', 'Joomla Site'); - - $mailer->addRecipient($masterEmail); - $mailer->setSubject( - sprintf('[%s] Emergency access login', $siteName) - ); - $mailer->setBody( - sprintf( - "Emergency access was used on %s\n\n" - . "Username: %s\n" - . "IP Address: %s\n" - . "Time: %s\n" - . "Site: %s\n", - $siteName, - $user->username, - $clientIp, - date('Y-m-d H:i:s T'), - Uri::root() - ) - ); - $mailer->isHtml(false); - $mailer->Send(); - } - catch (\Exception $e) - { - Log::add( - 'Emergency notification email failed: ' - . $e->getMessage(), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Ensure the master super admin user always exists. - * - * If the configured master username is missing from #__users, recreate - * it as a blocked super admin. The password is randomised so it cannot - * be used directly β€” emergency access uses the DB credential flow instead. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceMasterUser() - { - $email = self::MASTER_EMAIL; - - foreach ($this->getMasterUsernames() as $username) - { - $this->ensureMasterUserExists($username, $email); - } - } - - /** - * Ensure a single master user exists in #__users. - * - * @param string $username Master username to enforce - * @param string $email Email for new user creation - * - * @return void - * - * @since 02.29.00 - */ - private function ensureMasterUserExists($username, $email) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('id')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' . $db->quote($username)); - - $db->setQuery($query); - $userId = $db->loadResult(); - - if ($userId) - { - // User exists β€” make sure it's not blocked and is still Super Admin - $this->ensureSuperAdmin((int) $userId); - - return; - } - - // Create the master user with a random password - $randomPass = UserHelper::genRandomPassword(32); - $hashedPass = UserHelper::hashPassword($randomPass); - $now = Factory::getDate()->toSql(); - - // Use a unique email per username to avoid duplicate email conflicts - $primaryUser = $this->getMasterUsernames()[0]; - $userEmail = ($username === $primaryUser) ? $email : $username . '@mokoconsulting.tech'; - - $userData = (object) [ - 'name' => 'Webmaster', - 'username' => $username, - 'email' => $userEmail, - 'password' => $hashedPass, - 'block' => 0, - 'sendEmail' => 0, - 'registerDate' => $now, - 'lastvisitDate' => null, - 'params' => '{}', - ]; - - $db->insertObject('#__users', $userData, 'id'); - $newUserId = (int) $userData->id; - - // Add to Super Users group (group ID 8) - $mapping = (object) [ - 'user_id' => $newUserId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user "%s" (ID %d) recreated by MokoWaaS', $username, $newUserId), - Log::WARNING, - 'mokowaas' - ); - } - - /** - * Ensure a user is unblocked and belongs to the Super Users group. - * - * @param int $userId The user ID to verify - * - * @return void - * - * @since 02.01.08 - */ - protected function ensureSuperAdmin(int $userId) - { - $db = Factory::getDbo(); - - // Unblock if blocked - $query = $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('block') . ' = 0') - ->where($db->quoteName('id') . ' = ' . $userId) - ->where($db->quoteName('block') . ' = 1'); - - $db->setQuery($query); - $db->execute(); - - // Ensure Super Users group membership (group 8) - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__user_usergroup_map')) - ->where($db->quoteName('user_id') . ' = ' . $userId) - ->where($db->quoteName('group_id') . ' = 8'); - - $db->setQuery($query); - - if (!(int) $db->loadResult()) - { - $mapping = (object) [ - 'user_id' => $userId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user (ID %d) re-added to Super Users group by MokoWaaS', $userId), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Check if the current request IP is in the allowed list. - * - * Reads `$mokowaas_allowed_ips` from configuration.php. If the - * property is empty or not set, access is DENIED β€” an IP whitelist - * must be explicitly configured for emergency access to work. - * - * @return boolean True if the IP is allowed - * - * @since 02.01.08 - */ - protected function isIpAllowed() - { - $allowedRaw = trim($this->params->get('allowed_ips', '')); - - // No whitelist configured β€” all IPs are allowed - if (empty($allowedRaw)) - { - return true; - } - - $allowedIps = array_map('trim', explode(',', $allowedRaw)); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; - - return in_array($clientIp, $allowedIps, true); - } - - /** - * Build the placeholder β†’ value map from plugin params. - * - * @return array Associative array of placeholder => replacement value - * - * @since 02.01.08 - */ - protected function getPlaceholders() - { - return [ - '{{BRAND_NAME}}' => self::BRAND_NAME, - '{{COMPANY_NAME}}' => self::COMPANY_NAME, - '{{SUPPORT_URL}}' => self::SUPPORT_URL, - ]; - } - - /** - * Load language override templates and inject resolved strings into Joomla. - * - * Reads the override template shipped with the plugin, replaces - * {{BRAND_NAME}}, {{COMPANY_NAME}} and {{SUPPORT_URL}} with the - * values from plugin params, then injects the resolved strings into - * the active Language object. - * - * @return void - * - * @since 02.01.08 - */ - protected function loadLanguageOverrides() - { - $language = $this->app->getLanguage(); - $tag = $language->getTag(); - $pluginPath = JPATH_PLUGINS . '/system/mokowaas'; - $isAdmin = $this->app->isClient('administrator'); - - $overridePath = $isAdmin - ? $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini' - : $pluginPath . '/language/overrides/' . $tag . '.override.ini'; - - if (!file_exists($overridePath)) - { - return; - } - - $strings = $this->parseLanguageFile($overridePath); - $placeholders = $this->getPlaceholders(); - - foreach ($strings as $key => $value) - { - $language->_strings[$key] = str_replace( - array_keys($placeholders), - array_values($placeholders), - $value - ); - } - } - - /** - * Parse a language INI file and return the raw strings (with placeholders). - * - * @param string $filePath The path to the language file - * - * @return array Array of language strings (key => raw value) - * - * @since 02.01.08 - */ - protected function parseLanguageFile($filePath) - { - $strings = []; - - if (!file_exists($filePath)) - { - return $strings; - } - - $content = file_get_contents($filePath); - $lines = explode("\n", $content); - - foreach ($lines as $line) - { - $line = trim($line); - - if ($line === '' || $line[0] === ';') - { - continue; - } - - if (preg_match('/^([A-Z0-9_]+)="(.+)"$/i', $line, $matches)) - { - $strings[strtoupper($matches[1])] = $matches[2]; - } - } - - return $strings; - } - - - /** - * Event triggered after an extension's config is saved. - * - * Checks for maintenance action toggles (reset_hits, delete_versions). - * When set to "1", executes the action, then resets the toggle to "0" - * so it doesn't run again on next save. - * - * @param string $context The extension context (e.g. com_plugins.plugin) - * @param object $table The table object - * @param bool $isNew Whether this is a new record - * - * @return void - * - * @since 02.01.08 - */ - public function onExtensionAfterSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return; - } - - // Only act on our own plugin - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return; - } - - $params = new \Joomla\Registry\Registry($table->params); - $changed = false; - $app = $this->app; - - // Auto-generate health API token if missing - if (empty($params->get('health_api_token', ''))) - { - $params->set( - 'health_api_token', - bin2hex(random_bytes(32)) - ); - $changed = true; - - $app->enqueueMessage( - 'Health API token generated.', - 'message' - ); - } - - // Auto-set primary domain on first save - if (empty($params->get('primary_domain', ''))) - { - $host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - - if (!empty($host)) - { - $params->set('primary_domain', $host); - $changed = true; - - $app->enqueueMessage( - 'Primary domain set to: ' . $host, - 'message' - ); - } - } - - // Grafana auto-provisioning - $this->handleGrafanaProvisioning($params, $app); - - if ((int) $params->get('reset_hits', 0) === 1) - { - $count = $this->resetAllHits(); - $params->set('reset_hits', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Reset hit counters on %d articles.', $count), - 'message' - ); - - Log::add( - sprintf('All article hits reset (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - if ((int) $params->get('delete_versions', 0) === 1) - { - $count = $this->deleteAllVersions(); - $params->set('delete_versions', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Deleted %d version history records.', $count), - 'message' - ); - - Log::add( - sprintf('All content versions purged (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - // Content Sync: Push Now - if ((int) $params->get('sync_push_now', 0) === 1) - { - $params->set('sync_push_now', '0'); - $changed = true; - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($params->get('sync_targets', '[]'), true) ?: []; - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $targetCount = count($result['targets'] ?? []); - $app->enqueueMessage( - sprintf('Content sync pushed to %d target(s).', $targetCount), - 'message' - ); - } - catch (\Throwable $e) - { - $app->enqueueMessage( - 'Content sync failed: ' . $e->getMessage(), - 'error' - ); - } - } - - // Dev mode toggled off β€” cleanup - if ((int) $params->get('dev_mode', 0) === 0) - { - // Check if it was previously on by looking at current runtime state - $oldParams = new \Joomla\Registry\Registry( - $this->params->toString() - ); - - if ((int) $oldParams->get('dev_mode', 0) === 1) - { - $this->onDevModeDisabled(); - } - } - - if ($changed) - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('extension_id') . ' = ' - . (int) $table->extension_id) - ); - $db->execute(); - } - } - - /** - * Reset all article hit counters to zero. - * - * @return int Number of rows affected - * - * @since 02.01.08 - */ - protected function resetAllHits() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Delete all content version history records. - * - * @return int Number of rows deleted - * - * @since 02.01.08 - */ - protected function deleteAllVersions() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__history')) - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Event triggered after the route has been determined. - * - * Enforces tenant restrictions on admin routes β€” blocks access to - * components/views that non-master users should not see. - * - * @return void - * - * @since 02.01.08 - */ - public function onAfterRoute() - { - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->warnMissingLicenseKey(); - $this->enforceAdminRestrictions(); - $this->protectPlugin(); - } - - /** - * Inject visual branding into the document head. - * - * Fires just before is compiled β€” injects favicon, logo CSS, - * admin color scheme, and custom CSS. - * - * @return void - * - * @since 02.01.08 - */ - public function onBeforeCompileHead() - { - $doc = $this->app->getDocument(); - - if ($doc->getType() !== 'html') - { - return; - } - - // Inject robots meta tag for alias domains (frontend only) - if ($this->app->isClient('site')) - { - $this->injectAliasRobots($doc); - } - - // Demo mode banner (frontend only) β€” check if scheduled task is active - if ($this->app->isClient('site')) - { - $demoTask = $this->getDemoTaskParams(); - - if ($demoTask && (!isset($demoTask['banner_enabled']) || (int) $demoTask['banner_enabled'] === 1)) - { - $this->injectDemoBanner($doc, $demoTask); - } - } - - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->injectFavicon($doc); - $this->redirectHelpMenu($doc); - - // Hide MokoWaaS from plugin list for non-master users - if (!$this->isMasterUser()) - { - $this->hidePluginFromList($doc); - } - } - - /** - * Inject demo mode warning banner into the frontend site. - * - * Renders a fixed-position bar at the top of the page with a configurable - * message, color, optional countdown, and session-dismissable behavior. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.21.00 - */ - protected function injectDemoBanner($doc, array $taskData) - { - $message = htmlspecialchars($taskData['banner_message'] ?? 'This is a demo site. All changes will be reset periodically.', ENT_QUOTES, 'UTF-8'); - $bgColor = htmlspecialchars($taskData['banner_color'] ?? '#d9534f', ENT_QUOTES, 'UTF-8'); - $showCountdown = isset($taskData['show_countdown']) ? (int) $taskData['show_countdown'] : 1; - - // Get next_execution from the scheduled task - $resetAtMs = 0; - $nextExec = $taskData['next_execution'] ?? ''; - - if ($showCountdown && !empty($nextExec)) - { - $ts = strtotime($nextExec . ' UTC'); - - if ($ts > time()) - { - $resetAtMs = $ts * 1000; - } - } - - $countdownJs = ''; - - if ($showCountdown && $resetAtMs > 0) - { - $countdownJs = " - var resetAt = {$resetAtMs}; - var cdSpan = document.getElementById('mokowaas-demo-countdown'); - if (cdSpan) { - var tick = function() { - var now = Date.now(); - var diff = Math.max(0, Math.floor((resetAt - now) / 1000)); - if (diff <= 0) { cdSpan.textContent = ' β€” Reset imminent'; return; } - var parts = []; - var d = Math.floor(diff / 86400); - if (d >= 30) { - var mo = Math.floor(d / 30); - parts.push(mo + (mo === 1 ? ' month' : ' months')); - d = d % 30; - } - if (d >= 7) { - var w = Math.floor(d / 7); - parts.push(w + (w === 1 ? ' week' : ' weeks')); - d = d % 7; - } - if (d > 0) { parts.push(d + (d === 1 ? ' day' : ' days')); } - var rem = diff % 86400; - if (parts.length === 0) { - var h = Math.floor(rem / 3600); - var m = Math.floor((rem % 3600) / 60); - var s = rem % 60; - parts.push(h + 'h ' + m + 'm ' + s + 's'); - } else if (parts.length <= 2) { - var h = Math.floor(rem / 3600); - if (h > 0) { parts.push(h + 'h'); } - } - cdSpan.textContent = ' β€” Resets in ' + parts.join(' '); - }; - tick(); - setInterval(tick, 1000); - } - "; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - var bar = document.createElement('div'); - bar.id = 'mokowaas-demo-banner'; - bar.style.cssText = 'background:{$bgColor};color:#fff;padding:10px 20px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;text-align:center;'; - bar.innerHTML = '{$message}" . ($showCountdown ? "" : "") . "'; - - document.body.insertBefore(bar, document.body.firstChild); - - {$countdownJs} - }); - "); - } - - /** - * Get demo task params from #__scheduler_tasks if task is enabled. - * - * @return array|null Task params merged with task metadata, or null if no active task - * - * @since 02.29.00 - */ - protected function getDemoTaskParams(): ?array - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('params'), - $db->quoteName('state'), - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')) - ->where($db->quoteName('state') . ' = 1'); - - $db->setQuery($query); - $task = $db->loadAssoc(); - - if (!$task) - { - return null; - } - - $params = json_decode($task['params'] ?? '{}', true) ?: []; - $params['next_execution'] = $task['next_execution']; - $params['last_execution'] = $task['last_execution']; - - return $params; - } - catch (\Throwable $e) - { - return null; - } - } - - /** - * Hide MokoWaaS plugin and package from the extensions list via JS. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.03.04 - */ - protected function hidePluginFromList($doc) - { - $option = $this->app->input->get('option', ''); - $view = $this->app->input->get('view', ''); - - if ($option !== 'com_plugins' && $option !== 'com_installer') - { - return; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('tr').forEach(function(row) { - var text = row.textContent || ''; - if (text.indexOf('mokowaas') !== -1 || text.indexOf('MokoWaaS') !== -1) { - row.style.display = 'none'; - } - }); - }); - "); - } - - /** - * Redirect the admin Help menu link to the configured support URL. - * - * Joomla's Atum template hardcodes the Help link to help.joomla.org. - * This replaces it with the WaaS support URL via JS injection. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.10.00 - */ - protected function redirectHelpMenu($doc) - { - $supportUrl = self::SUPPORT_URL; - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) { - link.href = " . json_encode($supportUrl) . "; - link.target = '_blank'; - }); - }); - "); - } - - /** - * Protect the plugin from being disabled or uninstalled by non-master users. - * Does NOT self-heal (no lock) β€” master users can still disable if needed. - * - * @return void - * - * @since 02.03.04 - */ - protected function protectPlugin() - { - // Ensure protected flag is set (self-healing β€” runs once per session) - static $flagChecked = false; - - if (!$flagChecked) - { - $flagChecked = true; - $this->ensureProtectedFlag(); - } - - if ($this->isMasterUser()) - { - return; - } - - $option = $this->app->input->get('option', ''); - $task = $this->app->input->get('task', ''); - - // Block non-master from uninstalling MokoWaaS - if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error'); - $this->app->redirect('index.php?option=com_installer&view=manage'); - } - } - - // Block non-master from disabling via list toggle - if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - - // Block non-master from viewing or editing MokoWaaS plugin settings - if ($option === 'com_plugins') - { - $view = $this->app->input->get('view', ''); - $layout = $this->app->input->get('layout', ''); - $extensionId = (int) $this->app->input->get('extension_id', 0); - - if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' = ' . $extensionId) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')); - - if ((int) $db->setQuery($query)->loadResult() > 0) - { - $this->app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - } - } - - /** - * Ensure the protected flag is set on MokoWaaS extensions in the DB. - * - * Sets protected=1, locked=0 so the extension can't be disabled or - * uninstalled but can still receive updates and config changes. - * - * @return void - * - * @since 02.03.10 - */ - protected function ensureProtectedFlag() - { - try - { - $db = Factory::getDbo(); - - // Set protected=1, locked=0 on MokoWaaS extensions - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('protected') . ' = 1') - ->set($db->quoteName('locked') . ' = 0') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')') - ->where($db->quoteName('protected') . ' = 0'); - $db->setQuery($query); - $db->execute(); - - // Ensure update site stays enabled (protected extensions get their update site disabled by Joomla) - $query = $db->getQuery(true) - ->update($db->quoteName('#__update_sites') . ' AS us') - ->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id') - ->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id') - ->set('us.enabled = 1') - ->where('us.enabled = 0') - ->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Non-critical - } - } - - /** - * Check if any of the given extension IDs belong to MokoWaaS. - * - * @param array $ids Extension IDs to check - * - * @return bool - * - * @since 02.03.04 - */ - protected function isOurExtension(array $ids): bool - { - if (empty($ids)) - { - return false; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - - return (int) $db->setQuery($query)->loadResult() > 0; - } - - /** - * Prevent non-master users from disabling the plugin via save. - * - * @param string $context Extension context - * @param object $table Extension table row - * @param bool $isNew Whether this is a new record - * - * @return bool False to cancel save - * - * @since 02.03.04 - */ - public function onExtensionBeforeSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return true; - } - - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return true; - } - - // Non-master users cannot disable the plugin - if (!$this->isMasterUser() && (int) $table->enabled === 0) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $table->enabled = 1; - } - - return true; - } - - /** - * Cascade enable/disable state across all MokoWaaS extensions. - * - * When the core system plugin (plg_system_mokowaas) is disabled, - * all feature plugins and the cpanel module are also disabled. - * When re-enabled, they are re-enabled too. - * - * @param string $context The extension context - * @param array $pks Extension IDs being changed - * @param int $value New state (1=enabled, 0=disabled) - * - * @return void - * - * @since 02.32.00 - */ - public function onExtensionChangeState($context, $pks, $value) - { - if (empty($pks)) - { - return; - } - - try - { - $db = Factory::getDbo(); - - // Check if the core MokoWaaS plugin is among the changed extensions - $query = $db->getQuery(true) - ->select($db->quoteName('extension_id')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $db->setQuery($query); - $coreId = (int) $db->loadResult(); - - if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true)) - { - return; - } - - // Cascade to all MokoWaaS feature plugins + module - $mokoElements = [ - $db->quote('mokowaas_firewall'), - $db->quote('mokowaas_tenant'), - $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), - $db->quote('mod_mokowaas_cpanel'), - ]; - - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = ' . (int) $value) - ->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')'); - $db->setQuery($query); - $db->execute(); - $affected = $db->getAffectedRows(); - - // Also update module published state - if ($value == 0) - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 0') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - else - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 1') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - - $state = $value ? 'enabled' : 'disabled'; - $this->app->enqueueMessage( - "MokoWaaS: {$state} {$affected} associated extensions.", - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('MokoWaaS cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Filter admin menu items for non-master users. - * - * @param string $context Menu context - * @param array &$items Menu items (by reference) - * @param mixed $params Module params - * @param mixed $enabled Whether module is enabled - * - * @return void - * - * @since 02.01.08 - */ - public function onPreprocessMenuItems($context, &$items, $params, $enabled) - { - if (!$this->app->isClient('administrator')) - { - return; - } - - if ($this->isMasterUser()) - { - return; - } - - $hidden = $this->getHiddenMenuComponents(); - - if (empty($hidden)) - { - return; - } - - foreach ($items as $key => $item) - { - foreach ($hidden as $component) - { - if (isset($item->link) - && strpos($item->link, 'option=' . $component) !== false) - { - unset($items[$key]); - break; - } - } - } - } - - /** - * Enforce password policy before user save. - * - * @param array $oldUser Existing user data - * @param boolean $isNew Whether this is a new user - * @param array $newUser New user data being saved - * - * @return boolean True to allow save - * - * @since 02.01.08 - */ - public function onUserBeforeSave($oldUser, $isNew, $newUser) - { - if (empty($newUser['password_clear'])) - { - return true; - } - - $password = $newUser['password_clear']; - $errors = []; - - $minLen = (int) $this->params->get('password_min_length', 12); - - if (strlen($password) < $minLen) - { - $errors[] = sprintf( - 'Password must be at least %d characters.', $minLen - ); - } - - if ($this->params->get('password_require_uppercase', 1) - && !preg_match('/[A-Z]/', $password)) - { - $errors[] = 'Password must contain an uppercase letter.'; - } - - if ($this->params->get('password_require_number', 1) - && !preg_match('/\d/', $password)) - { - $errors[] = 'Password must contain a number.'; - } - - if ($this->params->get('password_require_special', 1) - && !preg_match('/[^A-Za-z0-9]/', $password)) - { - $errors[] = 'Password must contain a special character.'; - } - - if (!empty($errors)) - { - throw new \RuntimeException(implode(' ', $errors)); - } - - return true; - } - - - // ------------------------------------------------------------------ - // Diagnostics / Health Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle health check requests for external monitoring (e.g. Grafana). - * - * Intercepts requests with ?mokowaas=health, validates the API token, - * and returns a JSON payload with system diagnostics. Exits early to - * avoid Joomla routing overhead. - * - * @return void - * - * @since 02.01.22 - */ - /** - * Route MokoWaaS API requests. - * - * All endpoints share the same token auth and HTTPS enforcement. - * Endpoints: - * ?mokowaas=health β€” 16 diagnostic checks (GET) - * ?mokowaas=install β€” install extension from URL (POST) - * ?mokowaas=update β€” trigger Joomla update check (POST) - * ?mokowaas=cache β€” clear Joomla cache (POST) - * ?mokowaas=backup β€” trigger Akeeba Backup (POST) - * ?mokowaas=info β€” site info summary (GET) - * - * @param string $action The API action - * - * @return void - * - * @since 02.01.39 - */ - protected function handleMokoApi($action) - { - // Validate token for all endpoints - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken)) - { - $this->sendHealthResponse(503, ['error' => 'No API token']); - - return; - } - - $authHeader = $_SERVER['HTTP_AUTHORIZATION'] - ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] - ?? ''; - $providedToken = ''; - - if (stripos($authHeader, 'Bearer ') === 0) - { - $providedToken = trim(substr($authHeader, 7)); - } - else - { - $providedToken = $this->app->input->get('token', '', 'RAW'); - } - - // syncclear and syncpush handle their own auth via POST body - $selfAuthActions = ['syncclear', 'syncpush']; - - if (!\in_array($action, $selfAuthActions, true) && !hash_equals($expectedToken, $providedToken)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - switch ($action) - { - case 'health': - $this->handleHealthAction(); - break; - case 'install': - $this->handleInstallAction(); - break; - case 'update': - $this->handleUpdateAction(); - break; - case 'cache': - $this->handleCacheAction(); - break; - case 'backup': - $this->handleBackupAction(); - break; - case 'info': - $this->handleInfoAction(); - break; - case 'reset': - $this->handleDemoResetAction(); - break; - case 'snapshot': - $this->handleSnapshotAction(); - break; - case 'sync': - $this->handleSyncAction(); - break; - case 'sync-receive': - $this->handleSyncReceiveAction(); - break; - case 'syncclear': - $this->handleSyncClearAction(); - break; - case 'syncpush': - $this->handleSyncPushAction(); - break; - case 'extensions': - $this->handleExtensionsAction(); - break; - default: - $this->sendHealthResponse(400, [ - 'error' => 'Unknown action', - 'action' => $action, - 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'syncclear', 'extensions'], - ]); - break; - } - } - - // ------------------------------------------------------------------ - // API Actions - // ------------------------------------------------------------------ - - /** - * Handle demo site reset via API. - * - * POST /?mokowaas=reset - * Body: {"baseline": "default"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleDemoResetAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $baseline = $body['baseline'] - ?? 'default'; - - $service = $this->createDemoResetService(); - $result = $service->restoreSnapshot($baseline); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Reset failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle snapshot create/list via API. - * - * GET /?mokowaas=snapshot β€” list snapshots - * POST /?mokowaas=snapshot β€” create snapshot - * Body: {"name": "my-baseline"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleSnapshotAction() - { - $service = $this->createDemoResetService(); - - if ($this->app->input->getMethod() === 'GET') - { - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'snapshots' => $service->listSnapshots(), - ]); - - return; - } - - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'GET or POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $name = $body['name'] - ?? 'default'; - - $result = $service->createSnapshot($name); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Snapshot failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Create a DemoResetService instance from current plugin params. - * - * @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService - * @since 02.21.00 - */ - protected function createDemoResetService() - { - require_once __DIR__ . '/../Service/DemoResetService.php'; - - $includeMedia = (bool) $this->params->get('demo_snapshot_include_media', 1); - - return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($includeMedia); - } - - /** - * Calculate the next run time from a crontab expression. - * - * Supports standard 5-field crontab: minute hour day month weekday. - * Steps (e.g. every N), ranges, and wildcards are supported. - * - * @param string $cron Crontab expression - * - * @return string|null ISO datetime of next run, or null on invalid input - * - * @since 02.21.00 - */ - protected function ensureDemoResetTask(string $cron, string $baseline): void - { - try - { - $db = Factory::getDbo(); - - // Check if task already exists - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $existing = $db->loadAssoc(); - - // Convert cron to Joomla scheduler execution rule - $execRule = json_encode([ - 'rule-type' => 'cron-expression', - 'cron-expression' => $cron, - ]); - - $taskParams = json_encode(['baseline' => $baseline]); - - if ($existing) - { - // Update existing task - $query = $db->getQuery(true) - ->update($db->quoteName('#__scheduler_tasks')) - ->set($db->quoteName('execution_rules') . ' = ' . $db->quote($execRule)) - ->set($db->quoteName('params') . ' = ' . $db->quote($taskParams)) - ->set($db->quoteName('state') . ' = 1') - ->where($db->quoteName('id') . ' = ' . (int) $existing['id']); - - $db->setQuery($query); - $db->execute(); - } - else - { - // Create new task - $obj = (object) [ - 'title' => 'MokoWaaS Demo Reset', - 'type' => 'mokowaas.demo.reset', - 'execution_rules' => $execRule, - 'params' => $taskParams, - 'state' => 1, - 'created' => Factory::getDate()->toSql(), - 'next_execution' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__scheduler_tasks', $obj); - } - } - catch (\Throwable $e) - { - Log::add('Failed to create demo reset task: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Remove the demo reset scheduled task. - * - * @return void - * - * @since 02.28.00 - */ - protected function removeDemoResetTask(): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->delete($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Silent β€” table may not exist - } - } - - protected function calculateNextCronRun(string $cron): ?string - { - $parts = preg_split('/\s+/', trim($cron)); - - if (count($parts) !== 5) - { - return null; - } - - [$cronMin, $cronHour, $cronDay, $cronMonth, $cronWeekday] = $parts; - - // Start from next minute - $now = time(); - $check = $now - ($now % 60) + 60; - - // Check up to 366 days ahead - $maxChecks = 527040; // 366 * 24 * 60 - - for ($i = 0; $i < $maxChecks; $i++) - { - $min = (int) date('i', $check); - $hour = (int) date('G', $check); - $day = (int) date('j', $check); - $month = (int) date('n', $check); - $weekday = (int) date('w', $check); - - if ($this->cronFieldMatches($cronMin, $min, 0, 59) - && $this->cronFieldMatches($cronHour, $hour, 0, 23) - && $this->cronFieldMatches($cronDay, $day, 1, 31) - && $this->cronFieldMatches($cronMonth, $month, 1, 12) - && $this->cronFieldMatches($cronWeekday, $weekday, 0, 6)) - { - return gmdate('Y-m-d\TH:i:s\Z', $check); - } - - $check += 60; - } - - return null; - } - - /** - * Check if a value matches a crontab field expression. - * - * @param string $field Cron field (e.g. every-5, 1-15 range, 0-23, wildcard) - * @param int $value Current value to check - * @param int $min Minimum allowed value - * @param int $max Maximum allowed value - * - * @return bool - * - * @since 02.21.00 - */ - private function cronFieldMatches(string $field, int $value, int $min, int $max): bool - { - foreach (explode(',', $field) as $part) - { - $part = trim($part); - - // Step: every-N or range-with-step - if (str_contains($part, '/')) - { - [$range, $step] = explode('/', $part, 2); - $step = (int) $step; - - if ($step <= 0) - { - continue; - } - - if ($range === '*') - { - if (($value - $min) % $step === 0) - { - return true; - } - } - elseif (str_contains($range, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $range, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax && ($value - $rangeMin) % $step === 0) - { - return true; - } - } - - continue; - } - - // Wildcard - if ($part === '*') - { - return true; - } - - // Range: N-M - if (str_contains($part, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $part, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax) - { - return true; - } - - continue; - } - - // Exact value - if ((int) $part === $value) - { - return true; - } - } - - return false; - } - - /** - * Handle content sync push to configured targets. - * - * POST /?mokowaas=sync - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($this->params->get('sync_targets', '[]'), true) ?: []; - - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle incoming content sync payload (receiver side). - * - * POST /?mokowaas=sync-receive - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncReceiveAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $payload = json_decode(file_get_contents('php://input'), true); - - if (empty($payload['mokowaas_sync'])) - { - $this->sendHealthResponse(400, ['error' => 'Invalid payload β€” missing mokowaas_sync version']); - - return; - } - - require_once __DIR__ . '/../Service/ContentSyncReceiver.php'; - - $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); - $result = $receiver->receive($payload); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync receive failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Bulk-clear content on this site before a sync push. - * - * POST /?mokowaas=syncclear - * Body: {"token": "...", "types": ["articles", "categories", "menus", "modules"]} - * - * Deletes content directly via DB for speed β€” avoids the per-item - * Joomla API DELETE bottleneck. - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncClearAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - // Authenticate with health API token - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $types = $payload['types'] ?? []; - $cleared = []; - $db = Factory::getDbo(); - - try - { - if (\in_array('articles', $types, true)) - { - $db->setQuery('DELETE FROM ' . $db->quoteName('#__content'))->execute(); - $cleared[] = 'articles:' . $db->getAffectedRows(); - } - - if (\in_array('categories', $types, true)) - { - // Delete non-root content categories - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__categories')) - ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'categories:' . $db->getAffectedRows(); - } - - if (\in_array('menus', $types, true)) - { - // Delete non-root site menu items - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__menu')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'menus:' . $db->getAffectedRows(); - } - - if (\in_array('modules', $types, true)) - { - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__modules')) - ->where($db->quoteName('client_id') . ' = 0') - )->execute(); - $cleared[] = 'modules:' . $db->getAffectedRows(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'cleared' => $cleared, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Receive bulk content and insert locally via Joomla's Table API. - * - * POST /?mokowaas=syncpush - * Body: {"token": "...", "type": "articles", "items": [{...}, ...]} - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncPushAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $type = $payload['type'] ?? ''; - $items = $payload['items'] ?? []; - - if (empty($type) || empty($items)) - { - $this->sendHealthResponse(400, ['error' => 'Missing type or items']); - - return; - } - - try - { - $db = Factory::getDbo(); - $inserted = 0; - $now = Factory::getDate()->toSql(); - - switch ($type) - { - case 'articles': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'introtext' => $item['introtext'] ?? '', - 'fulltext' => $item['fulltext'] ?? '', - 'state' => (int) ($item['state'] ?? 1), - 'catid' => (int) ($item['catid'] ?? 2), - 'language' => $item['language'] ?? '*', - 'featured' => (int) ($item['featured'] ?? 0), - 'metadesc' => $item['metadesc'] ?? '', - 'metakey' => $item['metakey'] ?? '', - 'metadata' => $item['metadata'] ?? '{}', - 'created' => $item['created'] ?? $now, - 'modified' => $item['modified'] ?? $now, - 'publish_up' => $item['publish_up'] ?? $now, - 'images' => $item['images'] ?? '{}', - 'urls' => $item['urls'] ?? '{}', - 'attribs' => $item['attribs'] ?? '{}', - 'access' => (int) ($item['access'] ?? 1), - 'created_by' => 0, - 'asset_id' => 0, - ]; - $db->insertObject('#__content', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'categories': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'description' => $item['description'] ?? '', - 'published' => (int) ($item['published'] ?? 1), - 'language' => $item['language'] ?? '*', - 'extension' => $item['extension'] ?? 'com_content', - 'access' => (int) ($item['access'] ?? 1), - 'params' => $item['params'] ?? '{}', - 'metadata' => $item['metadata'] ?? '{}', - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - ]; - $db->insertObject('#__categories', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'menus': - foreach ($items as $item) - { - try - { - $alias = $item['alias'] ?? ''; - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $alias, - 'path' => $item['path'] ?? $alias, - 'menutype' => $item['menutype'] ?? 'mainmenu', - 'type' => $item['type'] ?? 'component', - 'link' => $item['link'] ?? '', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'home' => (int) ($item['home'] ?? 0), - 'params' => $item['params'] ?? '{}', - 'img' => $item['img'] ?? '', - 'access' => (int) ($item['access'] ?? 1), - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - 'client_id' => 0, - ]; - $db->insertObject('#__menu', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'modules': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'module' => $item['module'] ?? '', - 'position' => $item['position'] ?? '', - 'params' => $item['params'] ?? '{}', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'access' => (int) ($item['access'] ?? 1), - 'ordering' => (int) ($item['ordering'] ?? 0), - 'showtitle' => (int) ($item['showtitle'] ?? 1), - 'client_id' => 0, - ]; - $db->insertObject('#__modules', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - default: - $this->sendHealthResponse(400, ['error' => 'Unknown type: ' . $type]); - - return; - } - - // Rebuild nested set trees and asset table after insert - $this->repairAfterSync($type); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'type' => $type, - 'inserted' => $inserted, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync push failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Repair nested set trees and asset table after a bulk sync push. - * - * Categories and menus use nested sets (lft/rgt/level) which need - * rebuilding after direct DB inserts. Content needs asset entries - * for ACL to work. - * - * @param string $type Content type that was pushed - * - * @return void - * - * @since 02.31.00 - */ - private function repairAfterSync(string $type): void - { - try - { - $db = Factory::getDbo(); - - if ($type === 'categories') - { - // Rebuild the category nested set tree - $table = new \Joomla\CMS\Table\Category($db); - $table->rebuild(); - - // Ensure asset entries exist for each category - $db->setQuery( - $db->getQuery(true) - ->select('id, title, extension') - ->from($db->quoteName('#__categories')) - ->where($db->quoteName('id') . ' > 1') - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $cat) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = $cat->extension . '.category.' . $cat->id; - $asset->title = $cat->title; - $asset->rules = '{}'; - - // Parent asset = root - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__categories')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $cat->id) - )->execute(); - } - } - - if ($type === 'articles') - { - // Ensure asset entries exist for each article - $db->setQuery( - $db->getQuery(true) - ->select('id, title, catid') - ->from($db->quoteName('#__content')) - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $article) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = 'com_content.article.' . $article->id; - $asset->title = $article->title; - $asset->rules = '{}'; - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $article->id) - )->execute(); - } - } - - if ($type === 'menus') - { - // Rebuild menu nested set tree - $table = new \Joomla\CMS\Table\Menu($db); - $table->rebuild(); - } - } - catch (\Throwable $e) - { - Log::add('Asset repair failed for ' . $type . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * List installed extensions with version, status, and update server info. - * - * GET /?mokowaas=extensions - * Optional: ?type=plugin&search=moko&enabled=1 - * - * @return void - * @since 02.21.00 - */ - protected function handleExtensionsAction() - { - try - { - $db = Factory::getDbo(); - $input = $this->app->input; - - $query = $db->getQuery(true) - ->select([ - $db->quoteName('e.extension_id'), - $db->quoteName('e.name'), - $db->quoteName('e.type'), - $db->quoteName('e.element'), - $db->quoteName('e.folder'), - $db->quoteName('e.client_id'), - $db->quoteName('e.enabled'), - $db->quoteName('e.protected'), - $db->quoteName('e.locked'), - $db->quoteName('e.manifest_cache'), - ]) - ->from($db->quoteName('#__extensions', 'e')) - ->order($db->quoteName('e.type') . ' ASC, ' . $db->quoteName('e.name') . ' ASC'); - - $typeFilter = $input->get('type', '', 'CMD'); - - if ($typeFilter !== '') - { - $query->where($db->quoteName('e.type') . ' = ' . $db->quote($typeFilter)); - } - - $enabledFilter = $input->get('enabled', '', 'CMD'); - - if ($enabledFilter !== '') - { - $query->where($db->quoteName('e.enabled') . ' = ' . (int) $enabledFilter); - } - - $search = $input->get('search', '', 'STRING'); - - if ($search !== '') - { - $like = $db->quote('%' . $db->escape($search, true) . '%'); - $query->where( - '(' . $db->quoteName('e.name') . ' LIKE ' . $like - . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $like . ')' - ); - } - - $db->setQuery($query); - $rows = $db->loadAssocList(); - - // Get update sites - $usQuery = $db->getQuery(true) - ->select([ - $db->quoteName('us.name', 'site_name'), - $db->quoteName('us.location'), - $db->quoteName('us.enabled', 'site_enabled'), - $db->quoteName('usm.extension_id'), - ]) - ->from($db->quoteName('#__update_sites', 'us')) - ->innerJoin( - $db->quoteName('#__update_sites_extensions', 'usm') - . ' ON ' . $db->quoteName('us.update_site_id') - . ' = ' . $db->quoteName('usm.update_site_id') - ); - $db->setQuery($usQuery); - $updateSites = []; - - foreach ($db->loadAssocList() ?: [] as $us) - { - $updateSites[(int) $us['extension_id']] = [ - 'name' => $us['site_name'], - 'location' => $us['location'], - 'enabled' => (bool) $us['site_enabled'], - ]; - } - - $extensions = []; - - foreach ($rows as $row) - { - $manifest = json_decode($row['manifest_cache'] ?: '{}', true); - $extId = (int) $row['extension_id']; - - $ext = [ - 'extension_id' => $extId, - 'name' => $row['name'], - 'type' => $row['type'], - 'element' => $row['element'], - 'folder' => $row['folder'] ?: null, - 'client_id' => (int) $row['client_id'], - 'enabled' => (bool) $row['enabled'], - 'protected' => (bool) $row['protected'], - 'locked' => (bool) $row['locked'], - 'version' => $manifest['version'] ?? null, - 'author' => $manifest['author'] ?? null, - ]; - - if (isset($updateSites[$extId])) - { - $ext['update_server'] = $updateSites[$extId]; - } - - $extensions[] = $ext; - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'count' => count($extensions), - 'extensions' => $extensions, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Failed to list extensions', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Joomla update finder check. - * - * @return void - * @since 02.01.39 - */ - protected function handleUpdateAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - // Clear update cache and find updates - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__updates')) - ); - $db->execute(); - - // Trigger update finder - \Joomla\CMS\Updater\Updater::getInstance()->findUpdates(); - - // Count results - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $count = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'updates_found' => $count, - 'message' => $count . ' update(s) available', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Update check failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Clear Joomla cache. - * - * @return void - * @since 02.01.39 - */ - protected function handleCacheAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $cache = Factory::getCache(''); - $cache->clean(''); - - // Also clean admin cache - $adminCache = Factory::getCache('', 'callback', 'administrator'); - $adminCache->clean(''); - - // Clear opcache if available - if (function_exists('opcache_reset')) - { - opcache_reset(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'message' => 'Cache cleared', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Cache clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Akeeba Backup via frontend API. - * - * @return void - * @since 02.01.39 - */ - protected function handleBackupAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'ak_stats', $tables)) - { - $this->sendHealthResponse(404, [ - 'error' => 'Akeeba Backup not installed', - ]); - - return; - } - - // Get profile from request (default 1) - $body = json_decode(file_get_contents('php://input'), true); - $profile = (int) ($body['profile'] ?? 1); - - // Start backup via Akeeba's internal API - if (class_exists('\Akeeba\Engine\Platform')) - { - \Akeeba\Engine\Platform::getInstance()->load_configuration($profile); - $engine = \Akeeba\Engine\Factory::getEngineInstance(); - - $result = $engine->start($profile); - - $this->sendHealthResponse(200, [ - 'status' => 'started', - 'profile' => $profile, - 'message' => 'Backup started', - ]); - } - else - { - // Fallback: trigger via URL if frontend backup is enabled - $this->sendHealthResponse(501, [ - 'error' => 'Akeeba Engine not loadable', - 'message' => 'Use the Akeeba frontend URL or admin panel instead', - ]); - } - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Backup failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Return a compact site info summary. - * - * @return void - * @since 02.01.39 - */ - protected function handleInfoAction() - { - $config = Factory::getConfig(); - $db = Factory::getDbo(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content'))); - $articles = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users'))); - $users = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1')); - $extensions = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'site_name' => $config->get('sitename', ''), - 'site_url' => rtrim(Uri::root(), '/'), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'db_type' => $db->getName(), - 'debug' => (bool) $config->get('debug', 0), - 'sef' => (bool) $config->get('sef', 0), - 'caching' => (bool) $config->get('caching', 0), - 'articles' => $articles, - 'users' => $users, - 'extensions' => $extensions, - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - ]); - } - - /** - * Health check action β€” delegates to existing health check logic. - * - * @return void - * @since 02.01.39 - */ - protected function handleHealthAction() - { - // Token already validated by handleMokoApi() - // Collect diagnostics - $checks = $this->collectHealthChecks(); - - // Determine overall status and collect reasons - $overall = 'ok'; - $reasons = []; - - foreach ($checks as $name => $check) - { - $checkStatus = $check['status'] ?? 'ok'; - - if ($checkStatus === 'error') - { - $overall = 'error'; - $reasons[] = $name . ': ' . ($check['message'] ?? 'error'); - } - elseif ($checkStatus === 'degraded') - { - if ($overall !== 'error') - { - $overall = 'degraded'; - } - - // Build human-readable reason - if ($name === 'extensions' - && isset($check['pending_updates'])) - { - $reasons[] = $check['pending_updates'] - . ' extension update' - . ($check['pending_updates'] > 1 ? 's' : '') - . ' available'; - } - elseif ($name === 'filesystem' - && isset($check['free_disk_mb']) - && $check['free_disk_mb'] < 100) - { - $reasons[] = 'Low disk space: ' - . $check['free_disk_mb'] . ' MB free'; - } - elseif ($name === 'backup') - { - if (!empty($check['message'])) - { - $reasons[] = $check['message']; - } - elseif (isset($check['days_since']) - && $check['days_since'] > 7) - { - $reasons[] = 'Last backup ' - . $check['days_since'] . ' days ago'; - } - elseif (isset($check['last_status']) - && $check['last_status'] !== 'complete') - { - $reasons[] = 'Last backup status: ' - . $check['last_status']; - } - else - { - $reasons[] = 'Backup: degraded'; - } - } - elseif ($name === 'ssl' && isset($check['days_left'])) - { - $reasons[] = 'SSL expires in ' - . $check['days_left'] . ' days'; - } - elseif ($name === 'cron' && isset($check['failed_24h'])) - { - $reasons[] = $check['failed_24h'] - . ' scheduled task(s) failed'; - } - elseif ($name === 'config' && !empty($check['issues'])) - { - $reasons[] = implode(', ', $check['issues']); - } - else - { - $reasons[] = $name . ': degraded'; - } - } - } - - $payload = [ - 'status' => $overall, - 'reason' => implode('; ', $reasons) ?: null, - 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'checks' => $checks, - 'meta' => $this->collectHealthMeta(), - ]; - - $this->sendHealthResponse( - $overall === 'error' ? 503 : 200, - $payload - ); - } - - /** - * Collect all health check results. - * - * @return array Associative array of check name => result - * - * @since 02.01.22 - */ - protected function collectHealthChecks() - { - $checks = [ - 'database' => $this->checkDatabase(), - 'filesystem' => $this->checkFilesystem(), - 'cache' => $this->checkCache(), - 'extensions' => $this->checkExtensions(), - 'backup' => $this->checkAkeebaBackup(), - 'security' => $this->checkAdminTools(), - 'ssl' => $this->checkSsl(), - 'cron' => $this->checkScheduledTasks(), - 'errors' => $this->checkErrorLog(), - 'db_size' => $this->checkDatabaseSize(), - 'content' => $this->checkContent(), - 'users' => $this->checkUserActivity(), - 'mail' => $this->checkMail(), - 'seo' => $this->checkSeo(), - 'template' => $this->checkTemplate(), - 'config' => $this->checkConfigDrift(), - ]; - - return $checks; - } - - /** - * Collect metadata about the instance. - * - * @return array - * - * @since 02.01.22 - */ - protected function collectHealthMeta() - { - $config = Factory::getConfig(); - - return [ - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'server_name' => $config->get('sitename', ''), - 'server_time' => gmdate('Y-m-d\TH:i:s\Z'), - ]; - } - - /** - * Check database connectivity and query latency. - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkDatabase() - { - try - { - $db = Factory::getDbo(); - $start = microtime(true); - - $db->setQuery('SELECT 1'); - $db->execute(); - - $latencyMs = round((microtime(true) - $start) * 1000, 2); - - // Count users as a real-table sanity check - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $userCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'latency_ms' => $latencyMs, - 'driver' => $db->getName(), - 'users' => $userCount, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Database unreachable', - ]; - } - } - - /** - * Check filesystem health (writable dirs, disk space). - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkFilesystem() - { - $tmpWritable = is_writable(JPATH_ROOT . '/tmp'); - $logWritable = is_writable(JPATH_ROOT . '/administrator/logs'); - $cacheWritable = is_writable(JPATH_ROOT . '/cache'); - - $freeBytes = @disk_free_space(JPATH_ROOT); - $freeMb = $freeBytes !== false - ? round($freeBytes / 1048576) - : null; - - $allWritable = $tmpWritable && $logWritable && $cacheWritable; - - $status = 'ok'; - - if (!$allWritable) - { - $status = 'error'; - } - elseif ($freeMb !== null && $freeMb < 100) - { - $status = 'degraded'; - } - - // Total disk and site size - $totalBytes = @disk_total_space(JPATH_ROOT); - $totalMb = $totalBytes !== false - ? round($totalBytes / 1048576) - : null; - - // Site directory size (quick estimate via common dirs) - $siteMb = null; - - try - { - $siteSize = 0; - - foreach (['images', 'media', 'tmp', 'cache', - 'administrator/logs', 'administrator/cache'] as $dir) - { - $path = JPATH_ROOT . '/' . $dir; - - if (is_dir($path)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $path, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - $siteSize += $file->getSize(); - } - } - } - - $siteMb = round($siteSize / 1048576); - } - catch (\Exception $e) - { - // Ignore β€” siteMb stays null - } - - return [ - 'status' => $status, - 'tmp_writable' => $tmpWritable, - 'log_writable' => $logWritable, - 'cache_writable' => $cacheWritable, - 'free_disk_mb' => $freeMb, - 'total_disk_mb' => $totalMb, - 'site_size_mb' => $siteMb, - ]; - } - - /** - * Check Joomla cache status. - * - * @return array Check result - * - * @since 02.01.22 - */ - protected function checkCache() - { - $config = Factory::getConfig(); - $enabled = (bool) $config->get('caching', 0); - $handler = $config->get('cache_handler', 'file'); - - return [ - 'status' => 'ok', - 'enabled' => $enabled, - 'handler' => $handler, - ]; - } - - /** - * Check extension counts and update status. - * - * @return array Check result with extension metrics - * - * @since 02.01.22 - */ - protected function checkExtensions() - { - try - { - $db = Factory::getDbo(); - - // Count enabled extensions by type - $query = $db->getQuery(true) - ->select([ - $db->quoteName('type'), - 'COUNT(*) AS ' . $db->quoteName('total'), - ]) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('enabled') . ' = 1') - ->group($db->quoteName('type')); - - $db->setQuery($query); - $rows = $db->loadObjectList('type'); - - $counts = []; - - foreach ($rows as $type => $row) - { - $counts[$type] = (int) $row->total; - } - - // Check for available updates - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $pendingUpdates = (int) $db->loadResult(); - - $status = $pendingUpdates > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'counts' => $counts, - 'pending_updates' => $pendingUpdates, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Could not query extensions', - ]; - } - } - - /** - * Check Akeeba Backup status β€” last backup date, status, and profile. - * - * Queries the #__ak_stats table (Akeeba Backup) for the most recent - * backup record. Returns 'not_installed' if the table doesn't exist. - * - * @return array Check result with backup info - * - * @since 02.01.39 - */ - protected function checkAkeebaBackup() - { - try - { - $db = Factory::getDbo(); - - // Check if Akeeba Backup is installed - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - $akTable = $prefix . 'ak_stats'; - - if (!in_array($akTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Get the most recent backup - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('description'), - $db->quoteName('status'), - $db->quoteName('backupstart'), - $db->quoteName('backupend'), - $db->quoteName('profile_id'), - $db->quoteName('total_size'), - ]) - ->from($db->quoteName('#__ak_stats')) - ->order($db->quoteName('id') . ' DESC'); - - $db->setQuery($query, 0, 1); - $latest = $db->loadObject(); - - if (!$latest) - { - return [ - 'status' => 'degraded', - 'installed' => true, - 'message' => 'No backups found', - ]; - } - - // Count total backups and recent (last 7 days) - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ); - $totalBackups = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ->where($db->quoteName('backupstart') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $recentBackups = (int) $db->loadResult(); - - // Check if last backup is older than 7 days - $lastDate = $latest->backupstart; - $daysSince = (int) ((time() - strtotime($lastDate)) / 86400); - $backupSize = $latest->total_size - ? round($latest->total_size / 1048576) - : null; - - $status = 'ok'; - - if ($latest->status !== 'complete') - { - $status = 'degraded'; - } - elseif ($daysSince > 7) - { - $status = 'degraded'; - } - - return [ - 'status' => $status, - 'installed' => true, - 'last_backup' => $lastDate, - 'last_status' => $latest->status, - 'last_size_mb' => $backupSize, - 'days_since' => $daysSince, - 'profile_id' => (int) $latest->profile_id, - 'total_backups' => $totalBackups, - 'recent_7d' => $recentBackups, - 'description' => $latest->description, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check Admin Tools status β€” WAF status, security exceptions. - * - * Queries Admin Tools tables for firewall status and recent blocks. - * Returns 'not_installed' if tables don't exist. - * - * @return array Check result with security info - * - * @since 02.01.39 - */ - protected function checkAdminTools() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - // Check if Admin Tools is installed - $atTable = $prefix . 'admintools_log'; - - if (!in_array($atTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Count blocked requests in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $blocked24h = (int) $db->loadResult(); - - // Count blocked in last 7 days - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $blocked7d = (int) $db->loadResult(); - - // Check WAF config if available - $wafEnabled = null; - $wafTable = $prefix . 'admintools_wafconfig'; - - if (in_array($wafTable, $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('value')) - ->from($db->quoteName('#__admintools_wafconfig')) - ->where($db->quoteName('key') . ' = ' - . $db->quote('ipworkarounds')) - ); - $wafEnabled = $db->loadResult() !== null; - } - - return [ - 'status' => 'ok', - 'installed' => true, - 'blocked_24h' => $blocked24h, - 'blocked_7d' => $blocked7d, - 'waf_active' => $wafEnabled, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check SSL certificate expiry. - * - * @return array - * @since 02.01.39 - */ - protected function checkSsl() - { - try - { - $siteUrl = Uri::root(); - $host = parse_url($siteUrl, PHP_URL_HOST); - - if (empty($host) || parse_url($siteUrl, PHP_URL_SCHEME) !== 'https') - { - return ['status' => 'ok', 'https' => false]; - } - - $ctx = stream_context_create([ - 'ssl' => ['capture_peer_cert' => true, 'verify_peer' => false], - ]); - $stream = @stream_socket_client( - "ssl://{$host}:443", $errno, $errstr, 10, - STREAM_CLIENT_CONNECT, $ctx - ); - - if (!$stream) - { - return ['status' => 'degraded', 'https' => true, 'message' => 'Cannot connect']; - } - - $params = stream_context_get_params($stream); - $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); - fclose($stream); - - $expiresTs = $cert['validTo_time_t'] ?? 0; - $daysLeft = (int) (($expiresTs - time()) / 86400); - $issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown'; - $status = $daysLeft < 7 ? 'error' : ($daysLeft < 30 ? 'degraded' : 'ok'); - - return [ - 'status' => $status, - 'https' => true, - 'expires' => gmdate('Y-m-d', $expiresTs), - 'days_left' => $daysLeft, - 'issuer' => $issuer, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'https' => false]; - } - } - - /** - * Check Joomla scheduled tasks (Joomla 4.1+). - * - * @return array - * @since 02.01.39 - */ - protected function checkScheduledTasks() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'scheduler_tasks', $tables)) - { - return ['status' => 'ok', 'available' => false]; - } - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ); - $enabled = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC') - ); - $db->setQuery($db->getQuery(true), 0, 5); - // Re-run the query - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC'), - 0, 1 - ); - $last = $db->loadObject(); - - // Count failed in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('last_exit_code') . ' != 0') - ->where($db->quoteName('last_execution') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failed24h = (int) $db->loadResult(); - - $status = $failed24h > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'available' => true, - 'enabled_tasks' => $enabled, - 'failed_24h' => $failed24h, - 'last_run' => $last->last_execution ?? null, - 'last_exit_code' => $last ? (int) $last->last_exit_code : null, - 'last_task' => $last->title ?? null, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'available' => false]; - } - } - - /** - * Check PHP error log for recent errors. - * - * @return array - * @since 02.01.39 - */ - protected function checkErrorLog() - { - $logFile = JPATH_ROOT . '/administrator/logs/error.php'; - $altLog = ini_get('error_log'); - - $file = null; - - if (file_exists($logFile) && is_readable($logFile)) - { - $file = $logFile; - } - elseif ($altLog && file_exists($altLog) && is_readable($altLog)) - { - $file = $altLog; - } - - if (!$file) - { - return [ - 'status' => 'ok', - 'log_available' => false, - ]; - } - - $size = filesize($file); - $sizeMb = round($size / 1048576, 1); - - // Count recent lines (tail last 50 lines, count errors) - $lines = file_exists($file) ? @file($file) : []; - $recent = array_slice($lines, -50); - $errors24h = 0; - $lastError = null; - $yesterday = date('Y-m-d', strtotime('-1 day')); - - foreach ($recent as $line) - { - if (stripos($line, 'error') !== false - || stripos($line, 'fatal') !== false) - { - $errors24h++; - $lastError = trim(substr($line, 0, 200)); - } - } - - return [ - 'status' => 'ok', - 'log_available' => true, - 'log_size_mb' => $sizeMb, - 'recent_errors' => $errors24h, - 'last_error' => $lastError, - ]; - } - - /** - * Check database size and largest tables. - * - * @return array - * @since 02.01.39 - */ - protected function checkDatabaseSize() - { - try - { - $db = Factory::getDbo(); - $config = Factory::getConfig(); - $dbName = $config->get('db'); - - $db->setQuery( - "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables WHERE table_schema = " - . $db->quote($dbName) - ); - $totalMb = (float) $db->loadResult(); - - // Largest tables - $db->setQuery( - "SELECT table_name, " - . "ROUND((data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - . " ORDER BY (data_length + index_length) DESC LIMIT 5" - ); - $largest = []; - - foreach ($db->loadObjectList() as $t) - { - $largest[$t->table_name] = (float) $t->size_mb; - } - - // Table count - $db->setQuery( - "SELECT COUNT(*) FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - ); - $tableCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'total_mb' => $totalMb, - 'table_count' => $tableCount, - 'largest' => $largest, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_mb' => null]; - } - } - - /** - * Check content statistics. - * - * @return array - * @since 02.01.39 - */ - protected function checkContent() - { - try - { - $db = Factory::getDbo(); - - $counts = []; - - foreach ([ - 'articles' => '#__content', - 'categories' => '#__categories', - 'menu_items' => '#__menu', - 'modules' => '#__modules', - 'media' => '#__media_files', - ] as $label => $table) - { - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName($table)) - ); - $counts[$label] = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Table might not exist - } - } - - return [ - 'status' => 'ok', - 'counts' => $counts, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'counts' => []]; - } - } - - /** - * Check user activity β€” last login, active sessions, failed logins. - * - * @return array - * @since 02.01.39 - */ - protected function checkUserActivity() - { - try - { - $db = Factory::getDbo(); - - // Total users - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $totalUsers = (int) $db->loadResult(); - - // Last login - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('lastvisitDate')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('lastvisitDate') - . ' IS NOT NULL') - ->order($db->quoteName('lastvisitDate') . ' DESC'), - 0, 1 - ); - $lastLogin = $db->loadResult(); - - // Active sessions - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__session')) - ->where($db->quoteName('guest') . ' = 0') - ); - $activeSessions = (int) $db->loadResult(); - - // Failed logins (from action logs if available) - $failedLogins = 0; - - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__action_logs')) - ->where($db->quoteName('message_language_key') - . ' LIKE ' . $db->quote('%LOGIN_FAILED%')) - ->where($db->quoteName('log_date') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failedLogins = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Action logs might not track this - } - - return [ - 'status' => 'ok', - 'total_users' => $totalUsers, - 'last_login' => $lastLogin, - 'active_sessions' => $activeSessions, - 'failed_24h' => $failedLogins, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_users' => null]; - } - } - - /** - * Check mail system status. - * - * @return array - * @since 02.01.39 - */ - protected function checkMail() - { - try - { - $config = Factory::getConfig(); - $mailer = $config->get('mailer', 'mail'); - $from = $config->get('mailfrom', ''); - $smtpHost = $config->get('smtphost', ''); - - // Check mail queue if available - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - $queueCount = 0; - - if (in_array($prefix . 'mail_queue', $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__mail_queue')) - ); - $queueCount = (int) $db->loadResult(); - } - - return [ - 'status' => 'ok', - 'mailer' => $mailer, - 'from' => $from, - 'smtp_host' => $mailer === 'smtp' ? $smtpHost : null, - 'queue' => $queueCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'mailer' => null]; - } - } - - /** - * Check basic SEO health indicators. - * - * @return array - * @since 02.01.39 - */ - protected function checkSeo() - { - $robotsTxt = file_exists(JPATH_ROOT . '/robots.txt'); - $htaccess = file_exists(JPATH_ROOT . '/.htaccess'); - - // Check for sitemap - $sitemapXml = file_exists(JPATH_ROOT . '/sitemap.xml'); - $sitemapIdx = file_exists(JPATH_ROOT . '/sitemap_index.xml'); - - $config = Factory::getConfig(); - $sef = (bool) $config->get('sef', 0); - - return [ - 'status' => 'ok', - 'robots_txt' => $robotsTxt, - 'htaccess' => $htaccess, - 'sitemap' => $sitemapXml || $sitemapIdx, - 'sef_enabled' => $sef, - ]; - } - - /** - * Check active template info. - * - * @return array - * @since 02.01.39 - */ - protected function checkTemplate() - { - try - { - $db = Factory::getDbo(); - - // Site template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('home') . ' = 1') - ); - $siteTemplate = $db->loadResult() ?: 'unknown'; - - // Admin template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 1') - ->where($db->quoteName('home') . ' = 1') - ); - $adminTemplate = $db->loadResult() ?: 'unknown'; - - // Count template overrides - $overrideCount = 0; - $overridePath = JPATH_ROOT . '/templates/' . $siteTemplate . '/html'; - - if (is_dir($overridePath)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $overridePath, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - if ($file->isFile()) - { - $overrideCount++; - } - } - } - - return [ - 'status' => 'ok', - 'site_template' => $siteTemplate, - 'admin_template' => $adminTemplate, - 'override_count' => $overrideCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'site_template' => null]; - } - } - - /** - * Check configuration for common misconfigurations. - * - * @return array - * @since 02.01.39 - */ - protected function checkConfigDrift() - { - $config = Factory::getConfig(); - - $debug = (bool) $config->get('debug', 0); - $errorReport = $config->get('error_reporting', 'default'); - $gzip = (bool) $config->get('gzip', 0); - $sef = (bool) $config->get('sef', 0); - $sefRewrite = (bool) $config->get('sef_rewrite', 0); - $forceSSL = (int) $config->get('force_ssl', 0); - $caching = (bool) $config->get('caching', 0); - $lifetime = (int) $config->get('lifetime', 15); - $tmpPath = $config->get('tmp_path', ''); - $logPath = $config->get('log_path', ''); - - // Flag potential issues - $issues = []; - - if ($debug) - { - $issues[] = 'Debug mode is ON'; - } - - if ($errorReport === 'maximum' - || $errorReport === 'development') - { - $issues[] = 'Error reporting: ' . $errorReport; - } - - if ($forceSSL === 0) - { - $issues[] = 'Force SSL is OFF'; - } - - $status = empty($issues) ? 'ok' : 'degraded'; - - return [ - 'status' => $status, - 'debug' => $debug, - 'error_report' => $errorReport, - 'gzip' => $gzip, - 'sef' => $sef, - 'sef_rewrite' => $sefRewrite, - 'force_ssl' => $forceSSL, - 'caching' => $caching, - 'lifetime' => $lifetime, - 'issues' => $issues ?: null, - ]; - } - - /** - * Send a JSON health response and terminate execution. - * - * @param int $httpCode HTTP status code - * @param array $payload Data to encode as JSON - * - * @return void - * - * @since 02.01.22 - */ - protected function sendHealthResponse($httpCode, array $payload) - { - http_response_code($httpCode); - header('Content-Type: application/json; charset=utf-8'); - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('X-MokoWaaS-Health: 1'); - echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - $this->app->close(); - } - - // ------------------------------------------------------------------ - // Remote Install Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle remote extension install requests. - * - * POST /?mokowaas=install with a ZIP URL in the request body. - * Requires the same health API token + HTTPS. Downloads the ZIP - * and installs via Joomla's InstallerModel. - * - * Request: POST /?mokowaas=install - * Headers: Authorization: Bearer - * Body: {"url": "https://example.com/extension.zip"} - * - * @return void - * - * @since 02.01.39 - */ - protected function handleInstallAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - // Parse request body - $body = json_decode(file_get_contents('php://input'), true); - $url = $body['url'] ?? ''; - - if (empty($url)) - { - $this->sendHealthResponse(400, ['error' => 'url required']); - - return; - } - - // Validate URL is HTTPS - if (stripos($url, 'https://') !== 0) - { - $this->sendHealthResponse(400, ['error' => 'HTTPS URL required']); - - return; - } - - try - { - // Download the ZIP - $tmpFile = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_install_' . md5($url) . '.zip'; - - $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); - $zipData = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - if ($error || $code !== 200 || empty($zipData)) - { - $this->sendHealthResponse(502, [ - 'error' => 'Download failed', - 'http' => $code, - 'message' => $error ?: 'Empty response', - ]); - - return; - } - - file_put_contents($tmpFile, $zipData); - - // Extract ZIP to temp directory - $extractDir = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_extract_' . md5($url); - - if (is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - mkdir($extractDir, 0755, true); - - $zip = new \ZipArchive(); - - if ($zip->open($tmpFile) !== true) - { - @unlink($tmpFile); - $this->sendHealthResponse(500, ['error' => 'Failed to open ZIP']); - - return; - } - - $zip->extractTo($extractDir); - $zip->close(); - @unlink($tmpFile); - - // Install using Joomla's installer - $installer = \Joomla\CMS\Installer\Installer::getInstance(); - $result = $installer->install($extractDir); - - $this->rmdirRecursive($extractDir); - - if ($result) - { - $this->sendHealthResponse(200, [ - 'status' => 'installed', - 'message' => 'Extension installed successfully', - 'url' => $url, - ]); - } - else - { - $this->sendHealthResponse(500, [ - 'error' => 'Installation failed', - 'message' => 'Joomla installer returned false', - 'url' => $url, - ]); - } - } - catch (\Exception $e) - { - @unlink($tmpFile ?? ''); - - if (!empty($extractDir) && is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - $this->sendHealthResponse(500, [ - 'error' => 'Install exception', - 'message' => $e->getMessage(), - 'url' => $url, - ]); - } - } - - /** - * Recursively remove a directory. - * - * @param string $dir Directory path - * - * @return void - * - * @since 02.06.00 - */ - protected function rmdirRecursive(string $dir): void - { - if (!is_dir($dir)) - { - return; - } - - $items = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($items as $item) - { - if ($item->isDir()) - { - rmdir($item->getPathname()); - } - else - { - unlink($item->getPathname()); - } - } - - rmdir($dir); - } - - // ------------------------------------------------------------------ - // Site Alias handling - // ------------------------------------------------------------------ - - /** - * Get the alias configuration for the current request domain, if any. - * - * @return object|null Alias entry object or null if not an alias domain - * - * @since 02.01.43 - */ - /** - * Get the primary domain from Joomla config or by exclusion from aliases. - * - * @return string Primary domain hostname - * - * @since 02.03.05 - */ - protected function getPrimaryHost(): string - { - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain)) - { - return trim($primaryDomain); - } - - // Fallback: Joomla's $live_site - $liveSite = Factory::getConfig()->get('live_site', ''); - - if (!empty($liveSite)) - { - $host = parse_url($liveSite, PHP_URL_HOST); - - if ($host) - { - return $host; - } - } - - return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - } - - /** - * Get the dev alias domain (dev.{primary_domain}). - * - * @return string - * - * @since 02.31.00 - */ - protected function getDevAliasDomain(): string - { - $primary = $this->getPrimaryHost(); - - return !empty($primary) ? 'dev.' . $primary : ''; - } - - /** - * Check if the current request is on the dev alias domain. - * - * @return bool - * - * @since 02.31.00 - */ - protected function isDevAlias(): bool - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $devDomain = $this->getDevAliasDomain(); - - return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0; - } - - protected function getCurrentAlias() - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - - if (empty($currentHost)) - { - return null; - } - - // The only alias is dev.{primary_domain} - $devDomain = $this->getDevAliasDomain(); - - if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0) - { - return null; - } - - // Return a synthetic alias object for the dev domain - return (object) [ - 'domain' => $devDomain, - 'offline' => '0', - 'redirect_backend' => '0', - 'robots' => 'noindex, nofollow', - ]; - } - - /** - * Legacy compatibility β€” old getCurrentAlias read from site_aliases param. - * Now only returns the hardcoded dev.* alias. - */ - private function getCurrentAliasLegacy() - { - $aliases = $this->params->get('site_aliases', ''); - - if (empty($aliases)) - { - return null; - } - - // Subform returns JSON string, array, or stdClass - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - // Convert object to array (Joomla subform stores as {"key0":{...},"key1":{...}}) - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (!is_array($aliases) || empty($aliases)) - { - return null; - } - - // Look up the current host in the aliases list β€” if found, it's an alias - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (isset($alias->domain) && strcasecmp(rtrim(trim($alias->domain), '/'), $currentHost) === 0) - { - return $alias; - } - } - - return null; - } - - /** - * Handle site alias logic: offline page and backend redirect. - * - * Runs in onAfterInitialise so that Joomla's offline check in - * SiteApplication::doExecute() sees the updated config value. - * - * @return void - * - * @since 02.01.43 - */ - protected function handleSiteAlias() - { - // The dev alias (dev.{primary_domain}) always bypasses offline mode - if ($this->isDevAlias()) - { - $this->app->getConfig()->set('offline', 0); - - return; - } - } - - /** - * Inject robots meta tag for alias domains. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.01.43 - */ - protected function injectAliasRobots($doc) - { - // Always noindex/nofollow on the dev alias domain - if ($this->isDevAlias()) - { - $doc->setMetaData('robots', 'noindex, nofollow'); - } - - // Inject canonical URL pointing to the primary domain - $primaryHost = $this->getPrimaryHost(); - $currentUri = Uri::getInstance(); - $canonical = $currentUri->getScheme() . '://' . $primaryHost . $currentUri->toString(['path', 'query']); - $doc->addHeadLink($canonical, 'canonical'); - } - - // ------------------------------------------------------------------ - // Heartbeat (called from onExtensionAfterSave) - // ------------------------------------------------------------------ - // License key check (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Show a persistent admin warning if no license key is set on the - * MokoWaaS update site. - * - * Checks the extra_query column in #__update_sites for a dlid value. - * Also validates the key against MokoGitea on a heartbeat interval - * (once per day) and warns if the key is invalid or expired. - * - * @return void - * - * @since 02.31.00 - */ - protected function warnMissingLicenseKey(): void - { - // Only show to master users - if (!$this->isMasterUser()) - { - return; - } - - // Only warn once per session - $session = Factory::getSession(); - - if ($session->get('mokowaas.license_warned', false)) - { - return; - } - - $session->set('mokowaas.license_warned', true); - - try - { - $db = Factory::getDbo(); - - $query = $db->getQuery(true) - ->select($db->quoteName('extra_query')) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') - ->setLimit(1); - $db->setQuery($query); - $extraQuery = (string) $db->loadResult(); - - if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Required β€” ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Go to System β†’ Update Sites ' - . 'and enter your license key in the Download Key field for the MokoWaaS update site.', - 'warning' - ); - - return; - } - - // Extract the key value from extra_query - parse_str($extraQuery, $parsed); - $licenseKey = $parsed['dlid'] ?? ''; - - if (empty($licenseKey)) - { - return; - } - - // Heartbeat validation β€” check once per day - $session = Factory::getSession(); - $lastCheck = (int) $session->get('mokowaas.license_check', 0); - $now = time(); - - if (($now - $lastCheck) < 86400) - { - // Show cached warning if key was invalid last check - if ($session->get('mokowaas.license_invalid', false)) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid β€” ' - . 'Your license key could not be validated. Please verify your key in ' - . 'System β†’ Update Sites.', - 'error' - ); - } - - return; - } - - // Validate against MokoGitea - $session->set('mokowaas.license_check', $now); - - $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml' - . '?dlid=' . urlencode($licenseKey) - . '&domain=' . urlencode(Uri::root()); - - $ch = curl_init($validateUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - // Empty or non-200 means invalid key - $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false); - - $session->set('mokowaas.license_invalid', !$isValid); - - if (!$isValid) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid β€” ' - . 'Your license key could not be validated. Updates will not be available. ' - . 'Please verify your key in ' - . 'System β†’ Update Sites.', - 'error' - ); - } - } - catch (\Throwable $e) - { - // Silent β€” license check is non-critical - } - } - - // ------------------------------------------------------------------ - - /** - * Send heartbeat to the MokoWaaS monitoring receiver. - * - * Registers this site's primary domain with the Grafana provisioning system. - * The receiver writes a datasource YAML file and restarts Grafana. - * Alias domains are not registered to avoid duplicate datasource UIDs. - * - * @param \Joomla\Registry\Registry $params Plugin params - * @param \Joomla\CMS\Application\CMSApplication $app Application - * - * @return void - * - * @since 02.01.36 - */ - protected function handleGrafanaProvisioning($params, $app) - { - $healthToken = $params->get('health_api_token', ''); - - if (empty($healthToken)) - { - return; - } - - $siteUrl = rtrim(Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - - // Register primary domain - $this->sendHeartbeat($siteUrl, $siteName, $healthToken, $app); - - // Register alias domains (subform format) - $aliases = $params->get('site_aliases', ''); - - if (!empty($aliases)) - { - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (is_array($aliases)) - { - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (!empty($alias->domain)) - { - $domain = rtrim(trim($alias->domain), '/'); - $aliasUrl = 'https://' . preg_replace('#^https?://#i', '', $domain); - $this->sendHeartbeat($aliasUrl, $siteName, $healthToken, $app); - } - } - } - } - } - - /** - * Send a single heartbeat registration to the receiver. - * - * @param string $siteUrl Site URL to register - * @param string $siteName Display name for Grafana - * @param string $healthToken Health API bearer token - * @param object $app Application for messages - * - * @return void - * - * @since 02.01.39 - */ - protected function sendHeartbeat($siteUrl, $siteName, $healthToken, $app) - { - $payload = json_encode([ - 'site_url' => $siteUrl, - 'site_name' => $siteName, - 'health_token' => $healthToken, - 'action' => 'register', - ], JSON_UNESCAPED_SLASHES); - - $ch = curl_init(self::HEARTBEAT_URL . '/register'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, - ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - 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); - $error = curl_error($ch); - curl_close($ch); - - $body = json_decode($response, true); - - if ($error) - { - $app->enqueueMessage('Grafana heartbeat failed (' . $siteUrl . '): ' . $error, 'warning'); - Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); - } - elseif ($code === 200) - { - $status = $body['status'] ?? 'ok'; - $app->enqueueMessage( - 'Grafana heartbeat: ' . $siteUrl . ' ' . $status . ' (' . ($body['ds_uid'] ?? '') . ')', - 'message' - ); - } - else - { - $msg = sprintf('Grafana heartbeat failed (%s): HTTP %d β€” %s', - $siteUrl, $code, $body['error'] ?? $body['message'] ?? 'Unknown'); - $app->enqueueMessage($msg, 'warning'); - Log::add($msg, Log::WARNING, 'mokowaas'); - } - } - - // HTTPS / Session / License (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Redirect HTTP requests to HTTPS. - * - * @return void - * - * @since 02.01.08 - */ - /** - * Enforce development mode settings. - * - * When dev mode is ON: - * - Disable Joomla caching - * - Enable Joomla debug mode (Global Config) - * - Enable MokoOnyx template debug - * - Disable article hit recording - * - * When dev mode is OFF (and was previously on): - * - Reset all content version history - * - Reset article published dates to now - * - * @return void - * - * @since 02.01.15 - */ - protected function enforceDevMode() - { - if (!$this->params->get('dev_mode', 0)) - { - return; - } - - // Disable caching - $config = Factory::getConfig(); - $config->set('caching', 0); - - // Enable Joomla debug - $config->set('debug', 1); - - // Enable MokoOnyx template debug - $this->setTemplateParam('mokoonyx', 'debug', 1); - - // Show offline page on primary domain only β€” site aliases - // and dev.* subdomains bypass offline mode for development - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain) && $currentHost === $primaryDomain) - { - $config->set('offline', 1); - } - - // Suppress hit recording - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - )->execute(); - } - catch (\Throwable $e) - { - // Silent - } - } - - /** - * Actions to run when dev mode is turned off. - * - * Resets content versions and hits, disables debug. - * - * @return void - * - * @since 02.31.00 - */ - protected function onDevModeDisabled(): void - { - try - { - $db = Factory::getDbo(); - - // Delete all content version history - $db->setQuery( - $db->getQuery(true)->delete($db->quoteName('#__history')) - )->execute(); - - // Reset hits - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - )->execute(); - - // Disable debug - $this->setTemplateParam('mokoonyx', 'debug', 0); - - // Take site back online - Factory::getConfig()->set('offline', 0); - - $this->app->enqueueMessage( - 'Development mode disabled β€” versions cleared, hits reset, debug off, site online.', - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Set a parameter on a template style. - * - * @param string $template Template element name - * @param string $key Parameter key - * @param mixed $value Parameter value - * - * @return void - * - * @since 02.31.00 - */ - private function setTemplateParam(string $template, string $key, $value): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' . $db->quote($template)); - $db->setQuery($query); - $styles = $db->loadObjectList(); - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry($style->params ?: '{}'); - - if ($params->get($key) != $value) - { - $params->set($key, $value); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' . (int) $style->id) - )->execute(); - } - } - } - catch (\Throwable $e) - { - // Silent - } - } - - protected function enforceHttps() - { - if (!$this->params->get('force_https', 0)) - { - return; - } - - if ($this->app->isClient('cli')) - { - return; - } - - $isHttps = (!empty($_SERVER['HTTPS']) - && $_SERVER['HTTPS'] !== 'off') - || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; - - if (!$isHttps) - { - $this->app->redirect( - 'https://' . $_SERVER['HTTP_HOST'] - . $_SERVER['REQUEST_URI'], 301 - ); - } - } - - /** - * Enforce admin session idle timeout. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminSessionTimeout() - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - // Don't timeout the master user - if ($this->isMasterUser()) - { - return; - } - - // Trusted IPs β€” session lifetime already extended in boot() - if ($this->ipIsTrusted()) - { - return; - } - - $session = Factory::getSession(); - $lastHit = $session->get('mokowaas.last_activity', 0); - $now = time(); - - if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60)) - { - $this->app->logout(); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - $session->set('mokowaas.last_activity', $now); - } - - /** - * Check whether the current request IP matches any trusted IP entry. - * - * Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and - * wildcard patterns (e.g. 192.168.1.*). - * - * @return bool True if the current IP is in the trusted list. - * - * @since 02.11.00 - */ - protected function ipIsTrusted(): bool - { - $entries = $this->params->get('trusted_ips', ''); - - if (empty($entries)) - { - return false; - } - - // Subform stores as JSON string or array - if (\is_string($entries)) - { - $entries = json_decode($entries, true); - } - - if (!\is_array($entries)) - { - return false; - } - - $ip = $this->app - ? $this->app->input->server->getString('REMOTE_ADDR', '') - : ($_SERVER['REMOTE_ADDR'] ?? ''); - $ipLong = ip2long($ip); - - if ($ipLong === false) - { - return false; - } - - foreach ($entries as $entry) - { - if (empty($entry['enabled']) || empty($entry['ip'])) - { - continue; - } - - $range = trim($entry['ip']); - - // Wildcard: 192.168.1.* - if (str_contains($range, '*')) - { - $pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/'; - - if (preg_match($pattern, $ip)) - { - return true; - } - - continue; - } - - // CIDR: 10.0.0.0/8 - if (str_contains($range, '/')) - { - [$subnet, $bits] = explode('/', $range, 2); - $subnetLong = ip2long($subnet); - $mask = -1 << (32 - (int) $bits); - - if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask)) - { - return true; - } - - continue; - } - - // Exact match - if ($ip === $range) - { - return true; - } - } - - return false; - } - - - /** - * Override Joomla upload restrictions at runtime. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceUploadRestrictions() - { - $types = $this->params->get('upload_allowed_types', ''); - $maxMb = (int) $this->params->get('upload_max_size_mb', 0); - - if (empty($types) && $maxMb <= 0) - { - return; - } - - $config = $this->app->getConfig(); - - if (!empty($types)) - { - $config->set('upload_extensions', $types); - } - - if ($maxMb > 0) - { - $config->set('upload_maxsize', $maxMb); - } - } - - /** - * Enforce login support module URLs on admin requests. - * - * Checks the mod_loginsupport module params and corrects them if - * they have been changed away from the expected values. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceLoginSupportUrls() - { - $expected = [ - 'forum_url' => 'https://mokoconsulting.tech/support', - 'documentation_url' => 'https://mokoconsulting.tech/kb', - 'news_url' => 'https://mokoconsulting.tech/news', - ]; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' - . $db->quote('mod_loginsupport')); - - $db->setQuery($query); - $modules = $db->loadObjectList(); - - if (empty($modules)) - { - return; - } - - foreach ($modules as $module) - { - $params = new \Joomla\Registry\Registry( - $module->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $url) - { - if ($params->get($key) !== $url) - { - $params->set($key, $url); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $module->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - // ------------------------------------------------------------------ - // Tenant Restrictions (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Check admin routes against restriction rules and redirect if blocked. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminRestrictions() - { - // Master user bypasses ALL restrictions - if ($this->isMasterUser()) - { - return; - } - - $input = $this->app->input; - $option = $input->get('option', ''); - $view = $input->get('view', ''); - $task = $input->get('task', ''); - - // Disable install-from-URL for non-master users - if ($this->params->get('disable_install_url', 1) - && $option === 'com_installer' - && stripos($task, 'install') !== false - && $input->get('installtype') === 'url') - { - $this->blockAccess('Install from URL is disabled.'); - - return; - } - - $blocked = []; - - if ($this->params->get('restrict_installer', 1)) - { - // Allow the update view by default so tenants can update extensions - $allowUpdates = (int) $this->params->get('allow_extension_updates', 1); - - if ($allowUpdates && $option === 'com_installer' - && \in_array($view, ['update', 'updatesites'], true)) - { - // Do not block β€” update views are permitted - } - elseif ($option === 'com_installer') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $blocked[] = [ - 'option' => 'com_admin', - 'view' => 'sysinfo', - ]; - } - - if ($this->params->get('restrict_global_config', 1)) - { - $blocked[] = [ - 'option' => 'com_config', - 'view' => 'application', - ]; - // Also block empty view (default landing = global config) - if ($option === 'com_config' && $view === '') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('restrict_template_editing', 1)) - { - $blocked[] = [ - 'option' => 'com_templates', - 'view' => 'template', - ]; - } - - foreach ($blocked as $rule) - { - if ($option !== $rule['option']) - { - continue; - } - - if (isset($rule['view']) && $view !== $rule['view']) - { - continue; - } - - $this->blockAccess('Access restricted.'); - - return; - } - } - - /** - * Redirect to admin dashboard with an error message. - * - * @param string $message Error message to display - * - * @return void - * - * @since 02.01.08 - */ - protected function blockAccess($message) - { - $this->app->enqueueMessage($message, 'error'); - $this->app->redirect(Route::_('index.php', false)); - } - - /** - * Check whether the current user is the master WaaS user. - * - * @return boolean - * - * @since 02.01.08 - */ - protected function isMasterUser() - { - $user = $this->app->getIdentity(); - - if (!$user || $user->guest) - { - return false; - } - - return \in_array($user->username, $this->getMasterUsernames(), true); - } - - /** - * Decode obfuscated master usernames. - * - * @return array - * - * @since 02.29.01 - */ - private function getMasterUsernames(): array - { - if ($this->masterNames !== null) - { - return $this->masterNames; - } - - $this->masterNames = []; - - foreach (self::MASTER_KEYS as $encoded) - { - $raw = base64_decode($encoded); - $decoded = ''; - - for ($i = 0, $len = \strlen($raw); $i < $len; $i++) - { - $decoded .= \chr(\ord($raw[$i]) ^ self::MK); - } - - $this->masterNames[] = $decoded; - } - - return $this->masterNames; - } - - /** - * Build the list of components to hide from admin menu. - * - * Combines explicit hidden_menu_items config with components that - * are implicitly blocked by other restriction toggles. - * - * @return array Component option strings - * - * @since 02.01.08 - */ - protected function getHiddenMenuComponents() - { - $hidden = array_filter(array_map( - 'trim', - explode("\n", $this->params->get('hidden_menu_items', '')) - )); - - // Auto-hide components that are restricted (keep visible when updates are allowed) - if ($this->params->get('restrict_installer', 1) - && !$this->params->get('allow_extension_updates', 1)) - { - $hidden[] = 'com_installer'; - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $hidden[] = 'com_admin'; - } - - return array_unique($hidden); - } - - // ------------------------------------------------------------------ - // Atum Template Branding (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Enforce Atum admin template branding params. - * - * Sets logoBrandLarge, logoBrandSmall, loginLogo, and alt text - * in the Atum template style params. Uses the plugin's media - * folder as the image source. Only writes to DB when values - * have drifted. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAtumBranding() - { - $mediaBase = 'media/plg_system_mokowaas/'; - - // Logo params - $expected = [ - 'logoBrandLarge' => $mediaBase . 'logo.png', - 'logoBrandSmall' => $mediaBase . 'favicon_256.png', - 'loginLogo' => $mediaBase . 'logo.png', - 'logoBrandLargeAlt' => '', - 'logoBrandSmallAlt' => '', - 'loginLogoAlt' => '', - 'emptyLogoBrandLargeAlt' => '1', - 'emptyLogoBrandSmallAlt' => '1', - 'emptyLoginLogoAlt' => '1', - ]; - - // Hardcoded color scheme - $primary = self::COLOR_PRIMARY; - $sidebar = self::COLOR_SIDEBAR; - $link = self::COLOR_LINK; - - if (!empty($primary)) - { - // Convert hex to HSL for Atum's hue param - $hsl = $this->hexToHsl($primary); - - if ($hsl) - { - $expected['hue'] = sprintf( - 'hsl(%d, %d%%, %d%%)', - $hsl[0], $hsl[1], $hsl[2] - ); - } - - $expected['special-color'] = $primary; - } - - if (!empty($sidebar)) - { - $expected['header-color'] = $sidebar; - } - - if (!empty($link)) - { - $expected['link-color'] = $link; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' - . $db->quote('atum')) - ->where($db->quoteName('client_id') . ' = 1'); - - $db->setQuery($query); - $styles = $db->loadObjectList(); - - if (empty($styles)) - { - return; - } - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry( - $style->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $value) - { - if ($params->get($key) !== $value) - { - $params->set($key, $value); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $style->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - /** - * Convert a hex color to HSL values. - * - * @param string $hex Hex color (e.g., "#1a2744") - * - * @return array|null [hue, saturation%, lightness%] or null - * - * @since 02.01.08 - */ - protected function hexToHsl($hex) - { - $hex = ltrim($hex, '#'); - - if (strlen($hex) !== 6) - { - return null; - } - - $r = hexdec(substr($hex, 0, 2)) / 255; - $g = hexdec(substr($hex, 2, 2)) / 255; - $b = hexdec(substr($hex, 4, 2)) / 255; - - $max = max($r, $g, $b); - $min = min($r, $g, $b); - $l = ($max + $min) / 2; - - if ($max === $min) - { - return [0, 0, (int) round($l * 100)]; - } - - $d = $max - $min; - $s = $l > 0.5 - ? $d / (2 - $max - $min) - : $d / ($max + $min); - - if ($max === $r) - { - $h = ($g - $b) / $d + ($g < $b ? 6 : 0); - } - elseif ($max === $g) - { - $h = ($b - $r) / $d + 2; - } - else - { - $h = ($r - $g) / $d + 4; - } - - $h = $h / 6; - - return [ - (int) round($h * 360), - (int) round($s * 100), - (int) round($l * 100), - ]; - } - - // ------------------------------------------------------------------ - // Visual Branding (called from onBeforeCompileHead) - // ------------------------------------------------------------------ - - /** - * Replace the default favicon with a custom one. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc - * - * @return void - * - * @since 02.01.08 - */ - protected function injectFavicon($doc) - { - $mediaBase = 'media/plg_system_mokowaas/'; - $root = Uri::root(); - - // Remove all existing favicon/icon links - foreach ($doc->_links as $href => $attrs) - { - if (isset($attrs['relation']) - && strpos($attrs['relation'], 'icon') !== false) - { - unset($doc->_links[$href]); - } - } - - // SVG favicon (modern browsers, preferred) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.svg', - 'icon', - 'rel', - ['type' => 'image/svg+xml'] - ); - // ICO fallback (legacy browsers) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.ico', - 'alternate icon', - 'rel', - ['type' => 'image/vnd.microsoft.icon'] - ); - // PNG for Apple/Android - $doc->addHeadLink( - $root . $mediaBase . 'favicon_256.png', - 'apple-touch-icon', - 'rel', - ['sizes' => '256x256'] - ); - } - -} diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php deleted file mode 100644 index 432a8a74..00000000 --- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.33.00 - * PATH: /src/Field/AllowedIpsField.php - * BRIEF: Custom form field that displays the current IP whitelist - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Form\FormField; - -class AllowedIpsField extends FormField -{ - protected $type = 'AllowedIps'; - - protected function getInput() - { - $config = Factory::getApplication()->getConfig(); - $allowedRaw = $config->get('mokowaas_allowed_ips', ''); - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (empty($allowedRaw)) - { - $status = 'Not configured'; - $ipList = 'No IPs set β€” emergency access is blocked.'; - } - else - { - $ips = array_map('trim', explode(',', $allowedRaw)); - $status = '' - . count($ips) . ' IP(s) configured'; - $ipItems = []; - - foreach ($ips as $ip) - { - $match = ($ip === $currentIp) - ? ' your IP' - : ''; - $ipItems[] = '' . htmlspecialchars($ip) - . '' . $match; - } - - $ipList = implode(', ', $ipItems); - } - - $yourIp = '' . htmlspecialchars($currentIp) . ''; - - return '
    ' - . 'IP Whitelist: ' . $status . '
    ' - . 'Allowed IPs: ' . $ipList . '
    ' - . 'Your current IP: ' . $yourIp . '
    ' - . 'Set public ' - . '$mokowaas_allowed_ips = \'1.2.3.4,5.6.7.8\';' - . ' in configuration.php to change.' - . '
    '; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php deleted file mode 100644 index 3d4bf6f6..00000000 --- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.33.00 - * PATH: /src/Field/CurrentIpField.php - * BRIEF: Read-only field that displays the current user's IP address - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Form\FormField; - -class CurrentIpField extends FormField -{ - protected $type = 'CurrentIp'; - - protected function getInput() - { - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - return '
    ' - . 'Your current IP: ' - . '' . htmlspecialchars($currentIp) . ' ' - . '— add this to the table below to keep your session alive.' - . '
    '; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php deleted file mode 100644 index 97316c0a..00000000 --- a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php +++ /dev/null @@ -1,237 +0,0 @@ -getQuery(true) - ->select('*') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - $newTaskLink = Route::_('index.php?option=com_scheduler&task=task.add'); - - if (!$task) - { - return '
    ' - . 'No demo reset task configured. ' - . 'Create a Scheduled Task ' - . 'and select MokoWaaS Demo Reset to enable demo mode.
    '; - } - - $taskId = (int) $task['id']; - $state = (int) $task['state']; - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - // Parse schedule from execution_rules - $rules = json_decode($task['execution_rules'] ?? '{}', true); - $ruleType = $rules['rule-type'] ?? ''; - - switch ($ruleType) - { - case 'cron-expression': - $schedule = $rules['cron-expression'] ?? ''; - $friendlySchedule = $this->friendlySchedule($schedule); - break; - - case 'interval-minutes': - $mins = (int) ($rules['interval-minutes'] ?? 0); - - if ($mins >= 1440 && $mins % 1440 === 0) - { - $days = $mins / 1440; - $schedule = 'Every ' . $days . ' day' . ($days > 1 ? 's' : ''); - } - elseif ($mins >= 60 && $mins % 60 === 0) - { - $hours = $mins / 60; - $schedule = 'Every ' . $hours . ' hour' . ($hours > 1 ? 's' : ''); - } - else - { - $schedule = 'Every ' . $mins . ' minute' . ($mins !== 1 ? 's' : ''); - } - - $friendlySchedule = $schedule; - break; - - case 'interval-hours': - $hours = (int) ($rules['interval-hours'] ?? 0); - $schedule = 'Every ' . $hours . ' hour' . ($hours !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - case 'interval-days': - $days = (int) ($rules['interval-days'] ?? 0); - $schedule = 'Every ' . $days . ' day' . ($days !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - default: - $schedule = $ruleType ?: 'Not set'; - $friendlySchedule = 'Custom'; - } - - // Next execution - $nextExec = $task['next_execution'] ?? ''; - $nextFormatted = 'Not scheduled'; - $nextBadge = ''; - - if (!empty($nextExec) && $nextExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($nextExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $nextFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $nextFormatted = $nextExec; - } - - $diff = strtotime($nextExec . ' UTC') - time(); - - if ($diff <= 0) - { - $nextBadge = 'DUE'; - } - elseif ($diff < 3600) - { - $nextBadge = 'in ' . (int) ceil($diff / 60) . ' min'; - } - elseif ($diff < 86400) - { - $nextBadge = 'in ' . round($diff / 3600, 1) . 'h'; - } - else - { - $nextBadge = 'in ' . round($diff / 86400, 1) . 'd'; - } - } - - // Last execution - $lastExec = $task['last_execution'] ?? ''; - $lastFormatted = 'Never'; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($lastExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $lastFormatted = $lastExec; - } - } - - // State badge - $stateBadge = $state === 1 - ? 'Enabled' - : 'Disabled'; - - // Link to edit the task - $editLink = Route::_('index.php?option=com_scheduler&task=task.edit&id=' . $taskId); - - // Task params β€” default to On when keys are missing (matches form defaults) - $taskParams = json_decode($task['params'] ?? '{}', true) ?: []; - $bannerOn = !isset($taskParams['banner_enabled']) || (int) $taskParams['banner_enabled'] === 1; - $mediaOn = !isset($taskParams['include_media']) || (int) $taskParams['include_media'] === 1; - $countdownOn = !isset($taskParams['show_countdown']) || (int) $taskParams['show_countdown'] === 1; - - // Check if snapshot exists - $snapshotExists = is_dir(JPATH_ROOT . '/mokowaas-snapshots/default'); - - // Build info card - return '
    ' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '
    Status' . $stateBadge . '
    Schedule' . htmlspecialchars($friendlySchedule) . '
    Next Reset' . htmlspecialchars($nextFormatted) . ' ' . $nextBadge . '
    Last Reset' . htmlspecialchars($lastFormatted) . '
    Runs' . (int) ($task['times_executed'] ?? 0) . ' executed, ' . (int) ($task['times_failed'] ?? 0) . ' failed
    Baseline' . ($snapshotExists ? 'Saved' : 'Not taken yet') . '
    Banner' . ($bannerOn ? 'On' : 'Off') . ($countdownOn ? ' + countdown' : '') . '
    Images' . ($mediaOn ? 'Included' : 'Excluded') . '
    ' - . '' - . ' Manage Scheduled Task' - . '
    '; - } - - protected function getLabel() - { - return ''; - } - - /** - * Convert a cron expression to a human-readable string. - * - * @param string $cron Cron expression - * - * @return string - */ - private function friendlySchedule(string $cron): string - { - $map = [ - '* * * * *' => 'Every minute', - '*/5 * * * *' => 'Every 5 minutes', - '*/15 * * * *' => 'Every 15 minutes', - '*/30 * * * *' => 'Every 30 minutes', - '0 */1 * * *' => 'Every hour', - '0 */4 * * *' => 'Every 4 hours', - '0 */6 * * *' => 'Every 6 hours', - '0 */12 * * *' => 'Every 12 hours', - '0 0 * * *' => 'Daily at midnight', - '0 6 * * *' => 'Daily at 6:00 AM', - '0 0 * * 0' => 'Weekly (Sunday)', - '0 0 1 * *' => 'Monthly (1st)', - ]; - - return $map[$cron] ?? 'Custom'; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/NextResetField.php b/src/packages/plg_system_mokowaas/Field/NextResetField.php deleted file mode 100644 index ca4d64ef..00000000 --- a/src/packages/plg_system_mokowaas/Field/NextResetField.php +++ /dev/null @@ -1,156 +0,0 @@ -form) - { - $demoEnabled = (int) $this->form->getValue('demo_mode_enabled', 'params', 0) === 1; - } - - if (!$demoEnabled) - { - return 'Demo mode is off' - . ''; - } - - // Query the actual next_execution from the scheduled task - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - $db->quoteName('state'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - if (!$task) - { - return '
    No scheduled task found β€” save to create one automatically.
    ' - . ''; - } - - if ((int) $task['state'] !== 1) - { - return '
    Scheduled task is disabled.
    ' - . ''; - } - - $nextExec = $task['next_execution']; - $lastExec = $task['last_execution']; - - if (empty($nextExec) || $nextExec === '0000-00-00 00:00:00') - { - return '
    Waiting for first run...
    ' - . ''; - } - - // Convert to site timezone - $utcTimestamp = strtotime($nextExec); - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - try - { - $dt = new \DateTime('@' . $utcTimestamp); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $formatted = $dt->format('l, F j, Y \a\t g:i A T'); - } - catch (\Throwable $e) - { - $formatted = $nextExec . ' UTC'; - } - - // Relative time - $diff = $utcTimestamp - time(); - $relative = ''; - - if ($diff <= 0) - { - $relative = 'overdue'; - } - elseif ($diff < 3600) - { - $mins = (int) ceil($diff / 60); - $relative = 'in ' . $mins . ' min'; - } - elseif ($diff < 86400) - { - $hours = round($diff / 3600, 1); - $relative = 'in ' . $hours . 'h'; - } - else - { - $days = round($diff / 86400, 1); - $relative = 'in ' . $days . 'd'; - } - - // Last run info - $lastInfo = ''; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $lastDt = new \DateTime($lastExec); - $lastDt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastInfo = 'Last run: ' . $lastDt->format('M j, g:i A') . ''; - } - catch (\Throwable $e) - { - // skip - } - } - - return '
    ' - . '' - . ' ' - . htmlspecialchars($formatted) . ' ' - . $relative - . $lastInfo - . '' - . '
    '; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php deleted file mode 100644 index 5618bbf6..00000000 --- a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php +++ /dev/null @@ -1,175 +0,0 @@ - ['content', 'categories', 'fields', 'fields_values', 'fields_groups', 'tags', 'contentitem_tag_map', 'ucm_content', 'ucm_history'], - 'Users' => ['users', 'user_usergroup_map', 'user_profiles', 'usergroups', 'user_keys', 'user_mfa'], - 'Menus' => ['menu', 'menu_types'], - 'Modules' => ['modules', 'modules_menu'], - 'Assets' => ['assets'], - ]; - - protected function getInput() - { - $db = Factory::getDbo(); - $prefix = $db->getPrefix(); - $tables = $db->getTableList(); - - // Resolve selected values - $selected = $this->value; - - if ($selected === null || $selected === '') - { - $selected = self::DEFAULT_TABLES; - } - elseif (is_string($selected)) - { - $selected = array_filter(array_map('trim', explode("\n", $selected))); - } - - $selected = (array) $selected; - - // Flatten nested arrays from broken save format [["#__content"],["#__categories"]] - $selected = array_map(function ($v) { - return is_array($v) ? reset($v) : $v; - }, $selected); - - // Group tables - $grouped = []; - - foreach ($tables as $table) - { - if (strpos($table, $prefix) !== 0) - { - continue; - } - - $suffix = substr($table, strlen($prefix)); - $logical = '#__' . $suffix; - $group = 'Other'; - - foreach (self::TABLE_GROUPS as $groupName => $patterns) - { - if (in_array($suffix, $patterns, true)) - { - $group = $groupName; - break; - } - } - - $grouped[$group][] = $logical; - } - - // Build HTML select with optgroups - $size = (int) ($this->element['size'] ?? 15); - $html = ''; - - // "Reset to defaults" link - $defaultsJson = htmlspecialchars(json_encode(self::DEFAULT_TABLES), ENT_QUOTES, 'UTF-8'); - $html .= '
    ' - . ' Reset to defaults' - . '
    '; - - return $html; - } -} diff --git a/src/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini b/src/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini deleted file mode 100644 index ca5081b5..00000000 --- a/src/packages/plg_system_mokowaas/administrator/language/overrides/en-GB.override.ini +++ /dev/null @@ -1,120 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language Overrides -; Ingroup: MokoWaaS -; Version: 02.01.08 -; File: en-GB.override.ini -; Path: administrator/language/overrides/en-GB.override.ini -; Brief: Admin override TEMPLATE β€” placeholders resolved at runtime/install. -; Notes: Use {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} placeholders. -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} -; ----------------------------------------------------------------------------- - -; ===== Footer & template branding ===== -TPL_ATUM_POWERED_BY="Powered by {{BRAND_NAME}}" -MOD_FOOTER_LINE2="Powered by {{BRAND_NAME}}" - -; ===== Control panel greetings ===== -COM_CPANEL_WELCOME_TITLE="Welcome to {{BRAND_NAME}}!" -COM_CPANEL_MSG_WELCOME="Welcome to {{BRAND_NAME}}!" - -; ===== Help/Docs phrasing ===== -COM_ADMIN_HELP_SITE="{{BRAND_NAME}} Help" -COM_ADMIN_HELPSITE_FIELD_LABEL="{{BRAND_NAME}} Help" - -; ===== Generic replacements ===== -JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="{{BRAND_NAME}} Defaults" -COM_INSTALLER_TYPE_JOOMLA="{{BRAND_NAME}} Package" -LIB_JOOMLA="{{BRAND_NAME}} Library" - -; ===== System messages ===== -JERROR_JOOMLA="{{BRAND_NAME}} Error" -JFIELD_JOOMLA_LABEL="{{BRAND_NAME}} Field" - -; ===== AdminLogin Support ===== -MOD_LOGINSUPPORT_FORUM="{{COMPANY_NAME}} Support" -MOD_LOGINSUPPORT_DOCUMENTATION="{{BRAND_NAME}} Documentation" -MOD_LOGINSUPPORT_NEWS="{{COMPANY_NAME}} News" -MOD_LOGINSUPPORT_HEADLINE="Need help? Visit {{COMPANY_NAME}}:" -MOD_LOGINSUPPORT_XML_DESCRIPTION="This module displays useful links to {{COMPANY_NAME}} support on the login screen." -TPL_ATUM_BACKEND_LOGIN="{{BRAND_NAME}} Administrator Login" - -; ===== Error messages ===== -JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" - -; ===== Admin-specific branding ===== -COM_ADMIN_VIEW_HOME_TITLE="{{BRAND_NAME}} Control Panel" -JLIB_APPLICATION_ERROR_SAVE_FAILED="{{BRAND_NAME}} Error: Save failed" - -; ===== Module list workaround (RegularLabs) ===== -COM_MODULES_HEADING_POSITION="Position" - -; ===== Extensions ===== -COM_INSTALLER_TYPE_TYPE_JOOMLA="{{BRAND_NAME}}" -COM_INSTALLER_MSG_UPDATE_SUCCESS="Update installed successfully" - -; ===== Dashboard ===== -COM_CPANEL_WELCOME_BEGINNERS_TITLE="Welcome to {{BRAND_NAME}}!" -COM_CPANEL_WELCOME_BEGINNERS_MESSAGE="

    Community resources are available for new users.

    " -COM_CPANEL_MSG_STATS_COLLECTION_TITLE="Stats Collection in {{BRAND_NAME}}" - -; ===== Quick Icons ===== -PLG_QUICKICON_JOOMLAUPDATE_CHECKING="Checking {{BRAND_NAME}}…" -PLG_QUICKICON_JOOMLAUPDATE_ERROR="Unknown {{BRAND_NAME}}…" -PLG_QUICKICON_JOOMLAUPDATE_UPTODATE="{{BRAND_NAME}} is up to date." - -; ===== System Info ===== -COM_ADMIN_JOOMLA_VERSION="{{BRAND_NAME}} Version" -COM_ADMIN_HELP="{{BRAND_NAME}} Help" -COM_ADMIN_JOOMLA_COMPAT_PLUGIN="{{BRAND_NAME}} Backward Compatibility Plugin" - -; ===== Installer ===== -COM_INSTALLER_UPLOAD_INSTALL_JOOMLA_EXTENSION="Upload & Install {{BRAND_NAME}} Extension" -COM_INSTALLER_UNABLE_TO_INSTALL_JOOMLA_PACKAGE="The {{BRAND_NAME}} package cannot be installed through the Extension Manager. Please use the {{BRAND_NAME}} Update component to update." -COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTSET="The {{BRAND_NAME}} temporary folder is not set." -COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTWRITEABLE="The {{BRAND_NAME}} temporary folder is not writable or does not exist." -COM_INSTALLER_MSG_WARNINGS_UPDATE_NOTICE="Before updating ensure that the update is compatible with your {{BRAND_NAME}} installation.
    You are strongly advised to make a backup of your site's files and database before you start updating." - -; ===== Global Configuration ===== -COM_CONFIG_FIELD_METAVERSION_LABEL="{{BRAND_NAME}} Version" - -; ===== Update component ===== -COM_JOOMLAUPDATE_CONFIGURATION="{{BRAND_NAME}} Update: Options" -COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_NEXT="{{BRAND_NAME}} Next" -COM_JOOMLAUPDATE_CONFIG_SOURCES_DESC="Configure where {{BRAND_NAME}} gets its update information from." -COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_LABEL="Update Channel" -COM_JOOMLAUPDATE_VIEW_DEFAULT_TITLE="{{BRAND_NAME}} Update" -COM_JOOMLAUPDATE_VIEW_DEFAULT_DESCRIPTION="{{BRAND_NAME}} Update Component" -COM_JOOMLAUPDATE_NOCHANGE="{{BRAND_NAME}} is up to date." -COM_JOOMLAUPDATE_PREUPDATE_CHECK="{{BRAND_NAME}} Pre-Update Check" -COM_JOOMLAUPDATE_UPDATE_HEADER="{{BRAND_NAME}} Update" -COM_JOOMLAUPDATE_LIVEUPDATE="Live Update" -COM_JOOMLAUPDATE_CHECKEDFOR_UPDATES="Checked for {{BRAND_NAME}} updates." - -; ===== Privacy ===== -COM_PRIVACY_HEADING_CORE_CAPABILITIES="{{BRAND_NAME}} Core Capabilities" - -; ===== Database & Library errors ===== -JLIB_INSTALLER_MINIMUM_JOOMLA="You don't have the minimum {{BRAND_NAME}} version requirement of J%s" -JLIB_INSTALLER_ERROR_NOTFINDJOOMLAXMLSETUPFILE="Installer: Can't find {{BRAND_NAME}} XML setup file." - -; ===== Version and About ===== -JLIB_HTML_POWERED_BY="Powered by {{BRAND_NAME}}" -COM_ADMIN_HELP_DOCUMENTATION="{{BRAND_NAME}} Documentation" -COM_ADMIN_HELP_SUPPORT="{{BRAND_NAME}} Support" - -; ===== Akeeba Ticket System (ATS) ===== -COM_ATS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKETS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKET="{{BRAND_NAME}} Ticket" -COM_ATS_TITLE_NEWTICKET="New {{BRAND_NAME}} Ticket" -COM_ATS_TITLE_CATEGORIES="Ticket Categories" -COM_ATS_MSG_TICKET_SAVED="Your {{BRAND_NAME}} ticket has been saved." -COM_ATS_MSG_TICKET_CLOSED="Your {{BRAND_NAME}} ticket has been closed." -COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." -COM_ATS_LBL_POWEREDBY="Powered by {{BRAND_NAME}}" diff --git a/src/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini b/src/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini deleted file mode 100644 index 02fc67f7..00000000 --- a/src/packages/plg_system_mokowaas/administrator/language/overrides/en-US.override.ini +++ /dev/null @@ -1,120 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language Overrides -; Ingroup: MokoWaaS -; Version: 02.01.08 -; File: en-US.override.ini -; Path: administrator/language/overrides/en-US.override.ini -; Brief: Admin override TEMPLATE β€” placeholders resolved at runtime/install. -; Notes: Use {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} placeholders. -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} -; ----------------------------------------------------------------------------- - -; ===== Footer & template branding ===== -TPL_ATUM_POWERED_BY="Powered by {{BRAND_NAME}}" -MOD_FOOTER_LINE2="Powered by {{BRAND_NAME}}" - -; ===== Control panel greetings ===== -COM_CPANEL_WELCOME_TITLE="Welcome to {{BRAND_NAME}}!" -COM_CPANEL_MSG_WELCOME="Welcome to {{BRAND_NAME}}!" - -; ===== Help/Docs phrasing ===== -COM_ADMIN_HELP_SITE="{{BRAND_NAME}} Help" -COM_ADMIN_HELPSITE_FIELD_LABEL="{{BRAND_NAME}} Help" - -; ===== Generic replacements ===== -JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="{{BRAND_NAME}} Defaults" -COM_INSTALLER_TYPE_JOOMLA="{{BRAND_NAME}} Package" -LIB_JOOMLA="{{BRAND_NAME}} Library" - -; ===== System messages ===== -JERROR_JOOMLA="{{BRAND_NAME}} Error" -JFIELD_JOOMLA_LABEL="{{BRAND_NAME}} Field" - -; ===== AdminLogin Support ===== -MOD_LOGINSUPPORT_FORUM="{{COMPANY_NAME}} Support" -MOD_LOGINSUPPORT_DOCUMENTATION="{{BRAND_NAME}} Documentation" -MOD_LOGINSUPPORT_NEWS="{{COMPANY_NAME}} News" -MOD_LOGINSUPPORT_HEADLINE="Need help? Visit {{COMPANY_NAME}}:" -MOD_LOGINSUPPORT_XML_DESCRIPTION="This module displays useful links to {{COMPANY_NAME}} support on the login screen." -TPL_ATUM_BACKEND_LOGIN="{{BRAND_NAME}} Administrator Login" - -; ===== Error messages ===== -JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" - -; ===== Admin-specific branding ===== -COM_ADMIN_VIEW_HOME_TITLE="{{BRAND_NAME}} Control Panel" -JLIB_APPLICATION_ERROR_SAVE_FAILED="{{BRAND_NAME}} Error: Save failed" - -; ===== Module list workaround (RegularLabs) ===== -COM_MODULES_HEADING_POSITION="Position" - -; ===== Extensions ===== -COM_INSTALLER_TYPE_TYPE_JOOMLA="{{BRAND_NAME}}" -COM_INSTALLER_MSG_UPDATE_SUCCESS="Update installed successfully" - -; ===== Dashboard ===== -COM_CPANEL_WELCOME_BEGINNERS_TITLE="Welcome to {{BRAND_NAME}}!" -COM_CPANEL_WELCOME_BEGINNERS_MESSAGE="

    Community resources are available for new users.

    " -COM_CPANEL_MSG_STATS_COLLECTION_TITLE="Stats Collection in {{BRAND_NAME}}" - -; ===== Quick Icons ===== -PLG_QUICKICON_JOOMLAUPDATE_CHECKING="Checking {{BRAND_NAME}}…" -PLG_QUICKICON_JOOMLAUPDATE_ERROR="Unknown {{BRAND_NAME}}…" -PLG_QUICKICON_JOOMLAUPDATE_UPTODATE="{{BRAND_NAME}} is up to date." - -; ===== System Info ===== -COM_ADMIN_JOOMLA_VERSION="{{BRAND_NAME}} Version" -COM_ADMIN_HELP="{{BRAND_NAME}} Help" -COM_ADMIN_JOOMLA_COMPAT_PLUGIN="{{BRAND_NAME}} Backward Compatibility Plugin" - -; ===== Installer ===== -COM_INSTALLER_UPLOAD_INSTALL_JOOMLA_EXTENSION="Upload & Install {{BRAND_NAME}} Extension" -COM_INSTALLER_UNABLE_TO_INSTALL_JOOMLA_PACKAGE="The {{BRAND_NAME}} package cannot be installed through the Extension Manager. Please use the {{BRAND_NAME}} Update component to update." -COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTSET="The {{BRAND_NAME}} temporary folder is not set." -COM_INSTALLER_MSG_WARNINGS_JOOMLATMPNOTWRITEABLE="The {{BRAND_NAME}} temporary folder is not writable or does not exist." -COM_INSTALLER_MSG_WARNINGS_UPDATE_NOTICE="Before updating ensure that the update is compatible with your {{BRAND_NAME}} installation.
    You are strongly advised to make a backup of your site's files and database before you start updating." - -; ===== Global Configuration ===== -COM_CONFIG_FIELD_METAVERSION_LABEL="{{BRAND_NAME}} Version" - -; ===== Update component ===== -COM_JOOMLAUPDATE_CONFIGURATION="{{BRAND_NAME}} Update: Options" -COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_NEXT="{{BRAND_NAME}} Next" -COM_JOOMLAUPDATE_CONFIG_SOURCES_DESC="Configure where {{BRAND_NAME}} gets its update information from." -COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_LABEL="Update Channel" -COM_JOOMLAUPDATE_VIEW_DEFAULT_TITLE="{{BRAND_NAME}} Update" -COM_JOOMLAUPDATE_VIEW_DEFAULT_DESCRIPTION="{{BRAND_NAME}} Update Component" -COM_JOOMLAUPDATE_NOCHANGE="{{BRAND_NAME}} is up to date." -COM_JOOMLAUPDATE_PREUPDATE_CHECK="{{BRAND_NAME}} Pre-Update Check" -COM_JOOMLAUPDATE_UPDATE_HEADER="{{BRAND_NAME}} Update" -COM_JOOMLAUPDATE_LIVEUPDATE="Live Update" -COM_JOOMLAUPDATE_CHECKEDFOR_UPDATES="Checked for {{BRAND_NAME}} updates." - -; ===== Privacy ===== -COM_PRIVACY_HEADING_CORE_CAPABILITIES="{{BRAND_NAME}} Core Capabilities" - -; ===== Database & Library errors ===== -JLIB_INSTALLER_MINIMUM_JOOMLA="You don't have the minimum {{BRAND_NAME}} version requirement of J%s" -JLIB_INSTALLER_ERROR_NOTFINDJOOMLAXMLSETUPFILE="Installer: Can't find {{BRAND_NAME}} XML setup file." - -; ===== Version and About ===== -JLIB_HTML_POWERED_BY="Powered by {{BRAND_NAME}}" -COM_ADMIN_HELP_DOCUMENTATION="{{BRAND_NAME}} Documentation" -COM_ADMIN_HELP_SUPPORT="{{BRAND_NAME}} Support" - -; ===== Akeeba Ticket System (ATS) ===== -COM_ATS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKETS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKET="{{BRAND_NAME}} Ticket" -COM_ATS_TITLE_NEWTICKET="New {{BRAND_NAME}} Ticket" -COM_ATS_TITLE_CATEGORIES="Ticket Categories" -COM_ATS_MSG_TICKET_SAVED="Your {{BRAND_NAME}} ticket has been saved." -COM_ATS_MSG_TICKET_CLOSED="Your {{BRAND_NAME}} ticket has been closed." -COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." -COM_ATS_LBL_POWEREDBY="Powered by {{BRAND_NAME}}" diff --git a/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml b/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml deleted file mode 100644 index 301bc304..00000000 --- a/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml +++ /dev/null @@ -1,15 +0,0 @@ - -
    - - - - diff --git a/src/packages/plg_system_mokowaas/forms/trusted_ip_entry.xml b/src/packages/plg_system_mokowaas/forms/trusted_ip_entry.xml deleted file mode 100644 index 4e06f396..00000000 --- a/src/packages/plg_system_mokowaas/forms/trusted_ip_entry.xml +++ /dev/null @@ -1,28 +0,0 @@ - -
    - - - - - - - diff --git a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini deleted file mode 100644 index 5f0f536a..00000000 --- a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini +++ /dev/null @@ -1,201 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language -; Ingroup: MokoWaaS -; Version: 02.01.08 -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} used in override templates -; File: plg_system_mokowaas.ini -; Path: /src/language/en-GB/plg_system_mokowaas.ini -; Brief: English language strings for MokoWaaS system plugin -; Notes: Contains translatable strings for plugin functionality -; Variables: (none) -; ----------------------------------------------------------------------------- - -PLG_SYSTEM_MOKOWAAS="System - MokoWaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." - -PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_LABEL="Enable Branding" -PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_DESC="Enable or disable the branding overrides across the system." - -PLG_SYSTEM_MOKOWAAS_BRAND_NAME_LABEL="Brand Name" -PLG_SYSTEM_MOKOWAAS_BRAND_NAME_DESC="The brand name that replaces 'Joomla' throughout the interface. Used in all language overrides." -PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_LABEL="Company Name" -PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_DESC="Your company name, used in support links and footer text." -PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_LABEL="Support URL" -PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_DESC="URL for support and documentation links." - -; ===== WaaS Access fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_LABEL="WaaS Access Control" -PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_DESC="Master user enforcement and emergency access settings for the WaaS operator." - -PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_LABEL="Enforce Master User" -PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_DESC="Ensure the master super admin account always exists. If deleted, it will be recreated on next admin page load." -PLG_SYSTEM_MOKOWAAS_MASTER_USERNAME_LABEL="Master Username" -PLG_SYSTEM_MOKOWAAS_MASTER_USERNAME_DESC="Username for the persistent WaaS super admin account." -PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_LABEL="Master Email" -PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_DESC="Email address for the master super admin account." - -PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL="Emergency Access" -PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC="Allow login using database credentials as a two-factor emergency access method. Requires server file access to confirm." -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_SUCCESS="Emergency access LOGIN by {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_BLOCKED_IP="Emergency access BLOCKED (unauthorized IP) β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_WRONG_PASSWORD="Emergency access FAILED (wrong password) β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_VERIFY_FILE_CREATED="Emergency access verification file created β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_PENDING_FILE_DELETE="Emergency access pending file deletion β€” {username} from {ip}" - -PLG_SYSTEM_MOKOWAAS_ALLOWED_IPS_NOTE_LABEL="IP Whitelist" -PLG_SYSTEM_MOKOWAAS_ALLOWED_IPS_NOTE_DESC="Emergency access requires an IP whitelist. Set public $mokowaas_allowed_ips = '1.2.3.4,5.6.7.8'; in configuration.php. Emergency access is BLOCKED if no IPs are configured." - -; ===== Maintenance fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_LABEL="Maintenance" -PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_DESC="One-time maintenance actions. Set to Yes and save to execute. Resets to No automatically after execution." - -PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL="Development Mode" -PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC="Disables all Joomla caching at runtime. Useful during development and testing. Does not modify configuration.php." - -PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL="Reset All Hits" -PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC="Set all article hit counters to zero across the site. This action executes on save and resets to No." -PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL="Delete All Versions" -PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC="Purge all content version history from the database. This action executes on save and resets to No." - -; ===== Visual Branding fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_LABEL="Visual Branding" -PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_DESC="Admin color scheme and CSS injection. Logos and favicon are shipped in the plugin media folder." - -PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_LABEL="Logos & Favicon" -PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_DESC="Logos and favicon are automatically applied from the plugin media folder (/media/plg_system_mokowaas/). Replace logo.png, favicon.ico, and favicon_256.png to change them." -PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_LABEL="Primary Color" -PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_DESC="Main accent color used in the admin template header and buttons." -PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_LABEL="Sidebar Color" -PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_DESC="Background color for the admin sidebar navigation." -PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_LABEL="Header Color" -PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_DESC="Background color for the admin top header bar." -PLG_SYSTEM_MOKOWAAS_COLOR_LINK_LABEL="Link Color" -PLG_SYSTEM_MOKOWAAS_COLOR_LINK_DESC="Color for hyperlinks in the admin interface." -PLG_SYSTEM_MOKOWAAS_BRAND_ICON_LABEL="Brand Icon (FontAwesome)" -PLG_SYSTEM_MOKOWAAS_BRAND_ICON_DESC="FontAwesome unicode codepoint for the brand icon that replaces the Joomla logo icon. Enter the hex code only (e.g. f6d5 for fa-hat-cowboy). Find codes at fontawesome.com/icons." -PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_LABEL="Custom CSS" -PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_DESC="Additional CSS injected into admin pages. Use for fine-tuning visual presentation." - -; ===== Tenant Restrictions fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL="Tenant Restrictions" -PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC="Restrict admin features for non-master users. Master user always has full access." - -PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL="Restrict Extension Installer" -PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC="Block non-master users from installing or removing extensions." -PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL="Allow Extension Updates" -PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC="When the installer is restricted, still allow non-master users to update extensions." -PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL="Hide System Information" -PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC="Block non-master users from viewing PHP, database, and server information." -PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL="Restrict Global Configuration" -PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC="Block non-master users from changing Global Configuration. Component config is still accessible." -PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL="Restrict Template Code Editing" -PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC="Block non-master users from editing template source code. Template styles remain accessible." -PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL="Disable Install from URL" -PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from URL for ALL users (including master) as a safety measure." -PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" -PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." - -; ===== Content Sync fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL="Content Sync" -PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC="One-way content push to remote MokoWaaS sites. Syncs articles, categories, menus, and modules by alias." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL="Sync Targets" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC="Remote sites to push content to. Each target requires the site URL and that site's health API token." -PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL="Push Content Now" -PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push all content to all configured targets. Resets to No automatically." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)." - -; ===== Diagnostics fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" -PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." - -PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint" -PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at /?mokowaas=health. Requires a valid API token. A random token is generated automatically when enabled." -PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token" -PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." -PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL" -PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. https://grafana.example.com). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled." -PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_LABEL="Grafana API Key" -PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_DESC="Service account token or API key with Editor role in Grafana. Required for auto-provisioning the MokoWaaS datasource and dashboard." - -; ===== Security fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL="Security Hardening" -PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC="HTTPS enforcement, session timeouts, password policy, and upload restrictions." - -PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL="Force HTTPS" -PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC="Redirect all HTTP requests to HTTPS. Supports reverse proxy setups." -PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL="Admin Session Timeout" -PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC="Minutes of idle time before admin sessions expire. 0 uses the Joomla default." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL="Trusted IPs (No Session Timeout)" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC="Sessions from these IP addresses or ranges will never time out. Supports exact IPs, CIDR notation (e.g. 10.0.0.0/24), and wildcards (e.g. 192.168.1.*)." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ADDR_LABEL="IP / CIDR" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ADDR_DESC="An IP address, CIDR range, or wildcard pattern." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_LABEL_LABEL="Label" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_LABEL_DESC="A descriptive label for this entry (e.g. Office, VPN)." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ENABLED_LABEL="Enabled" -PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL="Minimum Password Length" -PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC="Minimum number of characters required for user passwords." -PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL="Require Uppercase" -PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL="Require Number" -PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL="Require Special Character" -PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL="Allowed Upload Types" -PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file extensions for media uploads." -PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)" -PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes." - -; ===== Demo Mode fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL="Demo Mode" -PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC="Configure demo site behavior with baseline snapshots and automatic periodic reset. When enabled, a warning banner is shown on the frontend." - -PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_LABEL="Enable Demo Mode" -PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_DESC="When enabled, shows a warning banner on the frontend and enables snapshot/restore functionality." -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_LABEL="Banner Message" -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_DESC="Message displayed in the demo warning banner on the frontend." -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_LABEL="Banner Color" -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_DESC="Background color for the demo warning banner." -PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_LABEL="Show Reset Countdown" -PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_DESC="Display a countdown timer in the banner showing time until the next scheduled reset." -PLG_SYSTEM_MOKOWAAS_DEMO_SCHEDULE_LABEL="Reset Schedule" -PLG_SYSTEM_MOKOWAAS_DEMO_SCHEDULE_DESC="How often the demo site resets. Select a preset or choose Custom to enter a crontab expression." -PLG_SYSTEM_MOKOWAAS_DEMO_CRON_LABEL="Custom Crontab" -PLG_SYSTEM_MOKOWAAS_DEMO_CRON_DESC="Crontab expression for the reset schedule. Format: minute hour day month weekday (e.g. 0 */6 * * * for every 6 hours)." -PLG_SYSTEM_MOKOWAAS_DEMO_NEXT_RESET_LABEL="Next Scheduled Reset" -PLG_SYSTEM_MOKOWAAS_DEMO_NEXT_RESET_DESC="Calculated automatically from the reset schedule. The banner countdown uses this timestamp." -PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_LABEL="Snapshot Tables" -PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_DESC="Database tables to include in snapshots. One per line, using #__ prefix. These tables will be truncated and restored during a reset." -PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_LABEL="Include Directories" -PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_DESC="Select which directories to include in the snapshot. Images contains uploaded media, Media contains extension assets." -PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_LABEL="Active Baseline Name" -PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_DESC="Name of the baseline snapshot used by admin toggles and scheduled tasks. Alphanumeric, hyphens, and underscores only." -PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_LABEL="Take Snapshot Now" -PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_DESC="Save the current site state as a baseline snapshot. Uses the Active Baseline Name above. Resets to No after execution." -PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_LABEL="Restore Baseline Now" -PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_DESC="Immediately restore the site to the active baseline snapshot. WARNING: This will overwrite current content. Resets to No after execution." - -; ===== Site Aliases fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases" -PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior." -PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_LABEL="Primary Domain" -PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_DESC="The primary domain for this site (e.g. waas.dev.mokoconsulting.tech). Used for backend redirect on alias domains. Do not include https:// prefix." -PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Domain Aliases" -PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Add domain aliases that serve as mirrors of this site. Each alias gets its own Grafana monitoring datasource." -PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_LABEL="Domain" -PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_DESC="The alias domain name (e.g. www.example.com). Do not include https:// prefix." -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_LABEL="Offline" -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_DESC="Show an offline maintenance page when visitors access the site through this alias domain." -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_LABEL="Offline Message" -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_DESC="Custom message to display when this alias is set to offline." -PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_LABEL="Robots" -PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_DESC="Meta robots directive for this alias domain. Use 'noindex, nofollow' to prevent search engines from indexing the alias." -PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_LABEL="Redirect Backend" -PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_DESC="Redirect admin panel requests on this alias to the primary domain. Frontend stays on the alias domain." diff --git a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini deleted file mode 100644 index 5f0f536a..00000000 --- a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini +++ /dev/null @@ -1,201 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language -; Ingroup: MokoWaaS -; Version: 02.01.08 -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} used in override templates -; File: plg_system_mokowaas.ini -; Path: /src/language/en-GB/plg_system_mokowaas.ini -; Brief: English language strings for MokoWaaS system plugin -; Notes: Contains translatable strings for plugin functionality -; Variables: (none) -; ----------------------------------------------------------------------------- - -PLG_SYSTEM_MOKOWAAS="System - MokoWaaS" -PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform." - -PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_LABEL="Enable Branding" -PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_DESC="Enable or disable the branding overrides across the system." - -PLG_SYSTEM_MOKOWAAS_BRAND_NAME_LABEL="Brand Name" -PLG_SYSTEM_MOKOWAAS_BRAND_NAME_DESC="The brand name that replaces 'Joomla' throughout the interface. Used in all language overrides." -PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_LABEL="Company Name" -PLG_SYSTEM_MOKOWAAS_COMPANY_NAME_DESC="Your company name, used in support links and footer text." -PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_LABEL="Support URL" -PLG_SYSTEM_MOKOWAAS_SUPPORT_URL_DESC="URL for support and documentation links." - -; ===== WaaS Access fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_LABEL="WaaS Access Control" -PLG_SYSTEM_MOKOWAAS_FIELDSET_WAAS_ACCESS_DESC="Master user enforcement and emergency access settings for the WaaS operator." - -PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_LABEL="Enforce Master User" -PLG_SYSTEM_MOKOWAAS_ENFORCE_MASTER_USER_DESC="Ensure the master super admin account always exists. If deleted, it will be recreated on next admin page load." -PLG_SYSTEM_MOKOWAAS_MASTER_USERNAME_LABEL="Master Username" -PLG_SYSTEM_MOKOWAAS_MASTER_USERNAME_DESC="Username for the persistent WaaS super admin account." -PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_LABEL="Master Email" -PLG_SYSTEM_MOKOWAAS_MASTER_EMAIL_DESC="Email address for the master super admin account." - -PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL="Emergency Access" -PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC="Allow login using database credentials as a two-factor emergency access method. Requires server file access to confirm." -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_SUCCESS="Emergency access LOGIN by {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_BLOCKED_IP="Emergency access BLOCKED (unauthorized IP) β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_WRONG_PASSWORD="Emergency access FAILED (wrong password) β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_VERIFY_FILE_CREATED="Emergency access verification file created β€” {username} from {ip}" -PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_PENDING_FILE_DELETE="Emergency access pending file deletion β€” {username} from {ip}" - -PLG_SYSTEM_MOKOWAAS_ALLOWED_IPS_NOTE_LABEL="IP Whitelist" -PLG_SYSTEM_MOKOWAAS_ALLOWED_IPS_NOTE_DESC="Emergency access requires an IP whitelist. Set public $mokowaas_allowed_ips = '1.2.3.4,5.6.7.8'; in configuration.php. Emergency access is BLOCKED if no IPs are configured." - -; ===== Maintenance fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_LABEL="Maintenance" -PLG_SYSTEM_MOKOWAAS_FIELDSET_MAINTENANCE_DESC="One-time maintenance actions. Set to Yes and save to execute. Resets to No automatically after execution." - -PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL="Development Mode" -PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC="Disables all Joomla caching at runtime. Useful during development and testing. Does not modify configuration.php." - -PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL="Reset All Hits" -PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC="Set all article hit counters to zero across the site. This action executes on save and resets to No." -PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL="Delete All Versions" -PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC="Purge all content version history from the database. This action executes on save and resets to No." - -; ===== Visual Branding fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_LABEL="Visual Branding" -PLG_SYSTEM_MOKOWAAS_FIELDSET_VISUAL_DESC="Admin color scheme and CSS injection. Logos and favicon are shipped in the plugin media folder." - -PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_LABEL="Logos & Favicon" -PLG_SYSTEM_MOKOWAAS_BRANDING_NOTE_DESC="Logos and favicon are automatically applied from the plugin media folder (/media/plg_system_mokowaas/). Replace logo.png, favicon.ico, and favicon_256.png to change them." -PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_LABEL="Primary Color" -PLG_SYSTEM_MOKOWAAS_COLOR_PRIMARY_DESC="Main accent color used in the admin template header and buttons." -PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_LABEL="Sidebar Color" -PLG_SYSTEM_MOKOWAAS_COLOR_SIDEBAR_DESC="Background color for the admin sidebar navigation." -PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_LABEL="Header Color" -PLG_SYSTEM_MOKOWAAS_COLOR_HEADER_DESC="Background color for the admin top header bar." -PLG_SYSTEM_MOKOWAAS_COLOR_LINK_LABEL="Link Color" -PLG_SYSTEM_MOKOWAAS_COLOR_LINK_DESC="Color for hyperlinks in the admin interface." -PLG_SYSTEM_MOKOWAAS_BRAND_ICON_LABEL="Brand Icon (FontAwesome)" -PLG_SYSTEM_MOKOWAAS_BRAND_ICON_DESC="FontAwesome unicode codepoint for the brand icon that replaces the Joomla logo icon. Enter the hex code only (e.g. f6d5 for fa-hat-cowboy). Find codes at fontawesome.com/icons." -PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_LABEL="Custom CSS" -PLG_SYSTEM_MOKOWAAS_CUSTOM_CSS_DESC="Additional CSS injected into admin pages. Use for fine-tuning visual presentation." - -; ===== Tenant Restrictions fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL="Tenant Restrictions" -PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC="Restrict admin features for non-master users. Master user always has full access." - -PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL="Restrict Extension Installer" -PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC="Block non-master users from installing or removing extensions." -PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL="Allow Extension Updates" -PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC="When the installer is restricted, still allow non-master users to update extensions." -PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL="Hide System Information" -PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC="Block non-master users from viewing PHP, database, and server information." -PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL="Restrict Global Configuration" -PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC="Block non-master users from changing Global Configuration. Component config is still accessible." -PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL="Restrict Template Code Editing" -PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC="Block non-master users from editing template source code. Template styles remain accessible." -PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL="Disable Install from URL" -PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from URL for ALL users (including master) as a safety measure." -PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" -PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." - -; ===== Content Sync fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL="Content Sync" -PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC="One-way content push to remote MokoWaaS sites. Syncs articles, categories, menus, and modules by alias." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL="Sync Targets" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC="Remote sites to push content to. Each target requires the site URL and that site's health API token." -PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL="Push Content Now" -PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push all content to all configured targets. Resets to No automatically." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings." -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label" -PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)." - -; ===== Diagnostics fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" -PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." - -PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint" -PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at /?mokowaas=health. Requires a valid API token. A random token is generated automatically when enabled." -PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token" -PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." -PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL" -PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. https://grafana.example.com). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled." -PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_LABEL="Grafana API Key" -PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_DESC="Service account token or API key with Editor role in Grafana. Required for auto-provisioning the MokoWaaS datasource and dashboard." - -; ===== Security fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL="Security Hardening" -PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC="HTTPS enforcement, session timeouts, password policy, and upload restrictions." - -PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL="Force HTTPS" -PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC="Redirect all HTTP requests to HTTPS. Supports reverse proxy setups." -PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL="Admin Session Timeout" -PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC="Minutes of idle time before admin sessions expire. 0 uses the Joomla default." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL="Trusted IPs (No Session Timeout)" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC="Sessions from these IP addresses or ranges will never time out. Supports exact IPs, CIDR notation (e.g. 10.0.0.0/24), and wildcards (e.g. 192.168.1.*)." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ADDR_LABEL="IP / CIDR" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ADDR_DESC="An IP address, CIDR range, or wildcard pattern." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_LABEL_LABEL="Label" -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_LABEL_DESC="A descriptive label for this entry (e.g. Office, VPN)." -PLG_SYSTEM_MOKOWAAS_TRUSTED_IP_ENABLED_LABEL="Enabled" -PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL="Minimum Password Length" -PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC="Minimum number of characters required for user passwords." -PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL="Require Uppercase" -PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL="Require Number" -PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL="Require Special Character" -PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL="Allowed Upload Types" -PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file extensions for media uploads." -PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)" -PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes." - -; ===== Demo Mode fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL="Demo Mode" -PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC="Configure demo site behavior with baseline snapshots and automatic periodic reset. When enabled, a warning banner is shown on the frontend." - -PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_LABEL="Enable Demo Mode" -PLG_SYSTEM_MOKOWAAS_DEMO_ENABLED_DESC="When enabled, shows a warning banner on the frontend and enables snapshot/restore functionality." -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_LABEL="Banner Message" -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_MSG_DESC="Message displayed in the demo warning banner on the frontend." -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_LABEL="Banner Color" -PLG_SYSTEM_MOKOWAAS_DEMO_BANNER_COLOR_DESC="Background color for the demo warning banner." -PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_LABEL="Show Reset Countdown" -PLG_SYSTEM_MOKOWAAS_DEMO_COUNTDOWN_DESC="Display a countdown timer in the banner showing time until the next scheduled reset." -PLG_SYSTEM_MOKOWAAS_DEMO_SCHEDULE_LABEL="Reset Schedule" -PLG_SYSTEM_MOKOWAAS_DEMO_SCHEDULE_DESC="How often the demo site resets. Select a preset or choose Custom to enter a crontab expression." -PLG_SYSTEM_MOKOWAAS_DEMO_CRON_LABEL="Custom Crontab" -PLG_SYSTEM_MOKOWAAS_DEMO_CRON_DESC="Crontab expression for the reset schedule. Format: minute hour day month weekday (e.g. 0 */6 * * * for every 6 hours)." -PLG_SYSTEM_MOKOWAAS_DEMO_NEXT_RESET_LABEL="Next Scheduled Reset" -PLG_SYSTEM_MOKOWAAS_DEMO_NEXT_RESET_DESC="Calculated automatically from the reset schedule. The banner countdown uses this timestamp." -PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_LABEL="Snapshot Tables" -PLG_SYSTEM_MOKOWAAS_DEMO_TABLES_DESC="Database tables to include in snapshots. One per line, using #__ prefix. These tables will be truncated and restored during a reset." -PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_LABEL="Include Directories" -PLG_SYSTEM_MOKOWAAS_DEMO_MEDIA_DESC="Select which directories to include in the snapshot. Images contains uploaded media, Media contains extension assets." -PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_LABEL="Active Baseline Name" -PLG_SYSTEM_MOKOWAAS_DEMO_ACTIVE_BASELINE_DESC="Name of the baseline snapshot used by admin toggles and scheduled tasks. Alphanumeric, hyphens, and underscores only." -PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_LABEL="Take Snapshot Now" -PLG_SYSTEM_MOKOWAAS_DEMO_TAKE_SNAPSHOT_DESC="Save the current site state as a baseline snapshot. Uses the Active Baseline Name above. Resets to No after execution." -PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_LABEL="Restore Baseline Now" -PLG_SYSTEM_MOKOWAAS_DEMO_RESTORE_NOW_DESC="Immediately restore the site to the active baseline snapshot. WARNING: This will overwrite current content. Resets to No after execution." - -; ===== Site Aliases fieldset ===== -PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_LABEL="Site Aliases" -PLG_SYSTEM_MOKOWAAS_FIELDSET_ALIASES_DESC="Configure additional domains that mirror this site. Each alias can have its own offline status, robots directive, and backend redirect behavior." -PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_LABEL="Primary Domain" -PLG_SYSTEM_MOKOWAAS_PRIMARY_DOMAIN_DESC="The primary domain for this site (e.g. waas.dev.mokoconsulting.tech). Used for backend redirect on alias domains. Do not include https:// prefix." -PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Domain Aliases" -PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Add domain aliases that serve as mirrors of this site. Each alias gets its own Grafana monitoring datasource." -PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_LABEL="Domain" -PLG_SYSTEM_MOKOWAAS_ALIAS_DOMAIN_DESC="The alias domain name (e.g. www.example.com). Do not include https:// prefix." -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_LABEL="Offline" -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_DESC="Show an offline maintenance page when visitors access the site through this alias domain." -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_LABEL="Offline Message" -PLG_SYSTEM_MOKOWAAS_ALIAS_OFFLINE_MSG_DESC="Custom message to display when this alias is set to offline." -PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_LABEL="Robots" -PLG_SYSTEM_MOKOWAAS_ALIAS_ROBOTS_DESC="Meta robots directive for this alias domain. Use 'noindex, nofollow' to prevent search engines from indexing the alias." -PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_LABEL="Redirect Backend" -PLG_SYSTEM_MOKOWAAS_ALIAS_REDIRECT_BACKEND_DESC="Redirect admin panel requests on this alias to the primary domain. Frontend stays on the alias domain." diff --git a/src/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini b/src/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini deleted file mode 100644 index d773e7b7..00000000 --- a/src/packages/plg_system_mokowaas/language/overrides/en-GB.override.ini +++ /dev/null @@ -1,66 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language Overrides -; Ingroup: MokoWaaS -; Version: 02.01.08 -; File: en-GB.override.ini -; Path: language/overrides/en-GB.override.ini -; Brief: Site/frontend override TEMPLATE β€” placeholders resolved at runtime/install. -; Notes: Use {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} placeholders. -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} -; ----------------------------------------------------------------------------- - -; ===== Footer & template branding ===== -TPL_CASSIOPEIA_POWERED_BY="Powered by {{BRAND_NAME}}" -MOD_FOOTER_LINE2="Powered by {{BRAND_NAME}}" - -; ===== Generic replacements ===== -JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="{{BRAND_NAME}} Defaults" -LIB_JOOMLA="{{BRAND_NAME}} Library" - -; ===== System messages ===== -JERROR_JOOMLA="{{BRAND_NAME}} Error" -JFIELD_JOOMLA_LABEL="{{BRAND_NAME}} Field" - -; ===== Error messages ===== -JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" - -; ===== Installer / Sample data ===== -INSTL_SITE_NAME_LABEL="{{BRAND_NAME}} Site Name" -INSTL_SAMPLE_BLOG_SET="{{BRAND_NAME}} Sample Data - Blog" -INSTL_SAMPLE_BROCHURE_SET="{{BRAND_NAME}} Sample Data - Brochure Site" -INSTL_SAMPLE_DATA_SET="{{BRAND_NAME}} Sample Data - Default" -INSTL_SAMPLE_LEARN_SET="{{BRAND_NAME}} Sample Data - Learn" -INSTL_SAMPLE_TESTING_SET="{{BRAND_NAME}} Sample Data - Testing" - -; ===== Login support ===== -MOD_LOGINSUPPORT_FORUM="{{COMPANY_NAME}} Support" -MOD_LOGINSUPPORT_DOCUMENTATION="{{BRAND_NAME}} Documentation" -MOD_LOGINSUPPORT_NEWS="{{COMPANY_NAME}} News" - -; ===== Site offline ===== -JOFFLINE_MESSAGE="This site is down for maintenance.
    Please check back again soon." - -; ===== Error pages ===== -JERROR_PAGE_NOT_FOUND="Page Not Found" -JERROR_AN_ERROR_HAS_OCCURRED="An error has occurred." -JLIB_APPLICATION_ERROR_COMPONENT_NOT_FOUND="Component not found." - -; ===== Version and About ===== -JLIB_HTML_POWERED_BY="Powered by {{BRAND_NAME}}" - -; ===== Akeeba Ticket System (ATS) ===== -COM_ATS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKETS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKET="{{BRAND_NAME}} Ticket" -COM_ATS_TITLE_NEWTICKET="New {{BRAND_NAME}} Ticket" -COM_ATS_TITLE_CATEGORIES="Ticket Categories" -COM_ATS_MSG_TICKET_SAVED="Your {{BRAND_NAME}} ticket has been saved." -COM_ATS_MSG_TICKET_CLOSED="Your {{BRAND_NAME}} ticket has been closed." -COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." -COM_ATS_LBL_POWEREDBY="Powered by {{BRAND_NAME}}" diff --git a/src/packages/plg_system_mokowaas/language/overrides/en-US.override.ini b/src/packages/plg_system_mokowaas/language/overrides/en-US.override.ini deleted file mode 100644 index a23405ed..00000000 --- a/src/packages/plg_system_mokowaas/language/overrides/en-US.override.ini +++ /dev/null @@ -1,66 +0,0 @@ -; ----------------------------------------------------------------------------- -; Copyright (C) 2025 Moko Consulting -; This file is part of a Moko Consulting project. -; SPDX-License-Identifier: GPL-3.0-or-later -; REPO: https://github.com/mokoconsulting-tech/mokowaas -; ----------------------------------------------------------------------------- -; FILE INFORMATION -; Defgroup: Joomla Language Overrides -; Ingroup: MokoWaaS -; Version: 02.01.08 -; File: en-US.override.ini -; Path: language/overrides/en-US.override.ini -; Brief: Site/frontend override TEMPLATE β€” placeholders resolved at runtime/install. -; Notes: Use {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} placeholders. -; Variables: {{BRAND_NAME}}, {{COMPANY_NAME}}, {{SUPPORT_URL}} -; ----------------------------------------------------------------------------- - -; ===== Footer & template branding ===== -TPL_CASSIOPEIA_POWERED_BY="Powered by {{BRAND_NAME}}" -MOD_FOOTER_LINE2="Powered by {{BRAND_NAME}}" - -; ===== Generic replacements ===== -JGLOBAL_FIELDSET_JOOMLA_DEFAULTS="{{BRAND_NAME}} Defaults" -LIB_JOOMLA="{{BRAND_NAME}} Library" - -; ===== System messages ===== -JERROR_JOOMLA="{{BRAND_NAME}} Error" -JFIELD_JOOMLA_LABEL="{{BRAND_NAME}} Field" - -; ===== Error messages ===== -JERROR_LAYOUT_ERROR_HAS_OCCURRED="ERROR OCCURRED" - -; ===== Installer / Sample data ===== -INSTL_SITE_NAME_LABEL="{{BRAND_NAME}} Site Name" -INSTL_SAMPLE_BLOG_SET="{{BRAND_NAME}} Sample Data - Blog" -INSTL_SAMPLE_BROCHURE_SET="{{BRAND_NAME}} Sample Data - Brochure Site" -INSTL_SAMPLE_DATA_SET="{{BRAND_NAME}} Sample Data - Default" -INSTL_SAMPLE_LEARN_SET="{{BRAND_NAME}} Sample Data - Learn" -INSTL_SAMPLE_TESTING_SET="{{BRAND_NAME}} Sample Data - Testing" - -; ===== Login support ===== -MOD_LOGINSUPPORT_FORUM="{{COMPANY_NAME}} Support" -MOD_LOGINSUPPORT_DOCUMENTATION="{{BRAND_NAME}} Documentation" -MOD_LOGINSUPPORT_NEWS="{{COMPANY_NAME}} News" - -; ===== Site offline ===== -JOFFLINE_MESSAGE="This site is down for maintenance.
    Please check back again soon." - -; ===== Error pages ===== -JERROR_PAGE_NOT_FOUND="Page Not Found" -JERROR_AN_ERROR_HAS_OCCURRED="An error has occurred." -JLIB_APPLICATION_ERROR_COMPONENT_NOT_FOUND="Component not found." - -; ===== Version and About ===== -JLIB_HTML_POWERED_BY="Powered by {{BRAND_NAME}}" - -; ===== Akeeba Ticket System (ATS) ===== -COM_ATS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKETS="{{BRAND_NAME}} Tickets" -COM_ATS_TITLE_TICKET="{{BRAND_NAME}} Ticket" -COM_ATS_TITLE_NEWTICKET="New {{BRAND_NAME}} Ticket" -COM_ATS_TITLE_CATEGORIES="Ticket Categories" -COM_ATS_MSG_TICKET_SAVED="Your {{BRAND_NAME}} ticket has been saved." -COM_ATS_MSG_TICKET_CLOSED="Your {{BRAND_NAME}} ticket has been closed." -COM_ATS_MSG_REPLY_SAVED="Your reply has been saved." -COM_ATS_LBL_POWEREDBY="Powered by {{BRAND_NAME}}" diff --git a/src/packages/plg_system_mokowaas/language/overrides/index.html b/src/packages/plg_system_mokowaas/language/overrides/index.html deleted file mode 100644 index 2efb97f3..00000000 --- a/src/packages/plg_system_mokowaas/language/overrides/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/packages/plg_system_mokowaas/media/favicon.ico b/src/packages/plg_system_mokowaas/media/favicon.ico deleted file mode 100644 index bb4ce548..00000000 Binary files a/src/packages/plg_system_mokowaas/media/favicon.ico and /dev/null differ diff --git a/src/packages/plg_system_mokowaas/media/favicon.svg b/src/packages/plg_system_mokowaas/media/favicon.svg deleted file mode 100644 index 0b898985..00000000 --- a/src/packages/plg_system_mokowaas/media/favicon.svg +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/packages/plg_system_mokowaas/media/favicon_256.png b/src/packages/plg_system_mokowaas/media/favicon_256.png deleted file mode 100644 index 3abb73d2..00000000 Binary files a/src/packages/plg_system_mokowaas/media/favicon_256.png and /dev/null differ diff --git a/src/packages/plg_system_mokowaas/media/logo.png b/src/packages/plg_system_mokowaas/media/logo.png deleted file mode 100644 index 4cf17316..00000000 Binary files a/src/packages/plg_system_mokowaas/media/logo.png and /dev/null differ diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml deleted file mode 100644 index b7ec28ca..00000000 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ /dev/null @@ -1,260 +0,0 @@ - - - - System - MokoWaaS - mokowaas - Moko Consulting - 2026-05-22 - Copyright (C) 2025 Moko Consulting. All rights reserved. - GNU General Public License version 3 or later; see LICENSE.md - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.33.00 - This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. - Moko\Plugin\System\MokoWaaS - script.php - - - script.php - Extension - Field - Helper - Service - forms - payload - services - language - administrator - - - - index.html - favicon.ico - favicon.svg - favicon_256.png - logo.png - - - - en-GB/plg_system_mokowaas.ini - en-US/plg_system_mokowaas.ini - - - - en-GB/plg_system_mokowaas.sys.ini - en-US/plg_system_mokowaas.sys.ini - - - - - language - - - - - -
    - - - - - - - - - - - - - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -
    diff --git a/src/packages/plg_system_mokowaas/payload/index.html b/src/packages/plg_system_mokowaas/payload/index.html deleted file mode 100644 index e69de29b..00000000 diff --git a/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini b/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini deleted file mode 100644 index 62115221..00000000 --- a/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini +++ /dev/null @@ -1,11 +0,0 @@ -; MokoWaaS Health Monitor Plugin -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -PLG_SYSTEM_MOKOWAAS_MONITOR="System - MokoWaaS Monitor" -PLG_SYSTEM_MOKOWAAS_MONITOR_DESC="Site health monitoring, Grafana heartbeat integration, and diagnostics." - -PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC="Monitoring" -PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC_DESC="Configure health monitoring and heartbeat settings." -PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_LABEL="Grafana Heartbeat" -PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_DESC="Send heartbeat registration to the Grafana monitoring receiver when plugin settings are saved." diff --git a/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php b/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php deleted file mode 100644 index 34a20c08..00000000 --- a/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php +++ /dev/null @@ -1,135 +0,0 @@ - 'onExtensionAfterSave', - ]; - } - - /** - * After saving this plugin or the core plugin, send heartbeat. - */ - public function onExtensionAfterSave($event): void - { - $context = $event->getArgument(0, ''); - $table = $event->getArgument(1); - - if ($context !== 'com_plugins.plugin' || !$table) - { - return; - } - - $element = $table->element ?? ''; - - // Trigger heartbeat when core or monitor plugin is saved - if (!\in_array($element, ['mokowaas', 'mokowaas_monitor'], true)) - { - return; - } - - if (!$this->params->get('heartbeat_enabled', 1)) - { - return; - } - - $this->sendHeartbeat(); - } - - /** - * Send heartbeat registration to the Grafana monitoring receiver. - */ - private function sendHeartbeat(): void - { - $coreParams = MokoWaaSHelper::getCoreParams(); - $healthToken = $coreParams->get('health_api_token', ''); - - if (empty($healthToken)) - { - return; - } - - $app = $this->getApplication(); - $siteUrl = rtrim(Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - - $payload = json_encode([ - 'site_url' => $siteUrl, - 'site_name' => $siteName, - 'health_token' => $healthToken, - 'action' => 'register', - ], JSON_UNESCAPED_SLASHES); - - $ch = curl_init(self::HEARTBEAT_URL . '/register'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, - ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - 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); - $error = curl_error($ch); - curl_close($ch); - - if ($error) - { - Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); - } - elseif ($code === 200) - { - $body = json_decode($response, true); - $app->enqueueMessage( - 'Grafana heartbeat: ' . ($body['status'] ?? 'ok'), - 'message' - ); - } - else - { - $body = json_decode($response, true); - Log::add( - \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), - Log::WARNING, - 'mokowaas' - ); - } - } -} diff --git a/src/packages/tpl_mokoonyx b/src/packages/tpl_mokoonyx deleted file mode 160000 index 16a7090f..00000000 --- a/src/packages/tpl_mokoonyx +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16a7090f29e0d8622a8bc6a72a7858ebaf6fac64 diff --git a/src/script.php b/src/script.php deleted file mode 100644 index eb04804c..00000000 --- a/src/script.php +++ /dev/null @@ -1,624 +0,0 @@ -cleanupLegacyExtensions(); - - $this->enablePlugin('system', 'mokowaas'); - $this->enablePlugin('system', 'mokowaas_firewall'); - $this->enablePlugin('system', 'mokowaas_tenant'); - $this->enablePlugin('system', 'mokowaas_devtools'); - $this->enablePlugin('system', 'mokowaas_monitor'); - $this->enablePlugin('webservices', 'mokowaas'); - $this->enablePlugin('task', 'mokowaasdemo'); - $this->enablePlugin('task', 'mokowaassync'); - - // Migrate params from core plugin to feature plugins (one-time) - $this->migrateFeatureParams(); - - // Set up cpanel module on the admin dashboard - $this->setupCpanelModule(); - - // Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level) - $this->protectExtensions(); - - // Clean up stale/duplicate update sites - $this->cleanupStaleUpdateSites(); - - // Trigger heartbeat registration - $this->sendHeartbeat(); - } - - /** - * Remove legacy/stale extension entries and filesystem remnants. - * - * The old standalone plugin was named "mokowaasbrand" (plg_system_mokowaasbrand). - * After the rewrite into the pkg_mokowaas package, the old entries and files - * may linger β€” especially on sites restored from old backups. - * - * @return void - * - * @since 02.21.00 - */ - private function cleanupLegacyExtensions(): void - { - try - { - $db = Factory::getDbo(); - - // Legacy element names to remove from #__extensions - $legacy = [ - $db->quote('mokowaasbrand'), - $db->quote('plg_system_mokowaasbrand'), - ]; - - // Delete from #__extensions - $query = $db->getQuery(true) - ->delete($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' IN (' . implode(',', $legacy) . ')'); - $db->setQuery($query); - $affected = $db->execute(); - $count = $db->getAffectedRows(); - - // Remove legacy plugin files from the filesystem - $legacyDirs = [ - JPATH_PLUGINS . '/system/mokowaasbrand', - ]; - - foreach ($legacyDirs as $dir) - { - if (is_dir($dir)) - { - $this->rmdirRecursive($dir); - } - } - - if ($count > 0) - { - Factory::getApplication()->enqueueMessage( - sprintf('Removed %d legacy MokoWaaS extension(s).', $count), - 'message' - ); - - Log::add( - sprintf('Cleaned up %d legacy MokoWaaS extension entries', $count), - Log::INFO, - 'mokowaas' - ); - } - } - catch (\Throwable $e) - { - Log::add('Legacy cleanup error: ' . $e->getMessage(), Log::WARNING, 'jerror'); - } - } - - /** - * Recursively remove a directory. - * - * @param string $dir Directory path - * - * @return void - * - * @since 02.21.00 - */ - private function rmdirRecursive(string $dir): void - { - $items = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($items as $item) - { - if ($item->isDir()) - { - @rmdir($item->getPathname()); - } - else - { - @unlink($item->getPathname()); - } - } - - @rmdir($dir); - } - - /** - * Enable a plugin by group and element. - * - * @param string $group Plugin group - * @param string $element Plugin element name - * - * @return void - * - * @since 2.2.0 - */ - private function enablePlugin(string $group, string $element): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote($group)) - ->where($db->quoteName('element') . ' = ' . $db->quote($element)); - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - Log::add('Error enabling plugin ' . $group . '/' . $element . ': ' . $e->getMessage(), Log::WARNING, 'jerror'); - } - } - - /** - * Set the protected flag on all MokoWaaS extensions. - * - * Joomla's protected flag prevents disabling and uninstalling at the - * framework level β€” no plugin-side interception needed. - * - * @return void - * - * @since 02.03.10 - */ - private function protectExtensions(): void - { - try - { - $db = Factory::getDbo(); - - // All MokoWaaS elements: package, system plugin, component, - // webservices plugins, task plugin - $elements = [ - $db->quote('pkg_mokowaas'), - $db->quote('mokowaas'), - $db->quote('mokowaas_firewall'), - $db->quote('mokowaas_tenant'), - $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), - $db->quote('com_mokowaas'), - $db->quote('mod_mokowaas_cpanel'), - $db->quote('mokowaasdemo'), - $db->quote('mokowaassync'), - $db->quote('perfectpublisher'), - $db->quote('mokoonyx'), - ]; - - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('protected') . ' = 1') - ->set($db->quoteName('locked') . ' = 0') - ->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')'); - $db->setQuery($query); - $db->execute(); - - // Ensure update server stays enabled - $this->enableUpdateServer(); - } - catch (\Throwable $e) - { - Log::add('Error protecting MokoWaaS extensions: ' . $e->getMessage(), Log::WARNING, 'jerror'); - } - } - - /** - * Remove stale and duplicate MokoWaaS update site entries. - * - * Keeps only the package-level update site pointing to the dynamic - * MokoGitea endpoint. Removes plugin-level entries, old static URLs, - * and orphaned #__updates rows tied to deleted update sites. - * - * @return void - * - * @since 02.31.00 - */ - private function cleanupStaleUpdateSites(): void - { - try - { - $db = Factory::getDbo(); - $dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml'; - - // Find all MokoWaaS update sites - $query = $db->getQuery(true) - ->select($db->quoteName(['update_site_id', 'location'])) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')'); - $db->setQuery($query); - $sites = $db->loadObjectList(); - - $keepId = null; - $removeIds = []; - - foreach ($sites as $site) - { - if ($site->location === $dynamicUrl && $keepId === null) - { - $keepId = (int) $site->update_site_id; - } - else - { - $removeIds[] = (int) $site->update_site_id; - } - } - - if (empty($removeIds)) - { - return; - } - - $idList = implode(',', $removeIds); - - // Remove orphaned #__updates rows - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__updates')) - ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') - )->execute(); - - // Remove link rows - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__update_sites_extensions')) - ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') - )->execute(); - - // Remove stale update sites - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__update_sites')) - ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')') - )->execute(); - - $count = count($removeIds); - - if ($count > 0) - { - Factory::getApplication()->enqueueMessage( - sprintf('Cleaned up %d stale MokoWaaS update site(s).', $count), - 'message' - ); - } - } - catch (\Throwable $e) - { - Log::add('Error cleaning up stale update sites: ' . $e->getMessage(), Log::WARNING, 'jerror'); - } - } - - /** - * Ensure the MokoWaaS update server entry stays enabled and points - * to the correct dynamic endpoint with the license key attached. - * - * Migrates legacy static URLs (raw/branch/main/updates.xml) to the - * dynamic MokoGitea update feed, and syncs the license key from - * plugin params into extra_query so Joomla sends it as dlid. - * - * @return void - * - * @since 02.21.00 - */ - private function enableUpdateServer(): void - { - try - { - $db = Factory::getDbo(); - - $staticUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml'; - - // Migrate old dynamic URL to static raw file URL - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__update_sites')) - ->set($db->quoteName('location') . ' = ' . $db->quote($staticUrl)) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') - ->where($db->quoteName('location') . ' != ' . $db->quote($staticUrl)) - ); - $db->execute(); - - // Enable all MokoWaaS update sites - $query = $db->getQuery(true) - ->update($db->quoteName('#__update_sites')) - ->set($db->quoteName('enabled') . ' = 1') - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')'); - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - Log::add('Error enabling update server: ' . $e->getMessage(), Log::WARNING, 'jerror'); - } - } - - /** - * Send heartbeat to the MokoWaaS monitoring receiver. - * - * @return void - * - * @since 02.03.08 - */ - private function sendHeartbeat(): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('params')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $params = json_decode((string) $db->setQuery($query)->loadResult()); - - $healthToken = $params->health_api_token ?? ''; - - if (empty($healthToken)) - { - return; - } - - $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - - $payload = json_encode([ - 'site_url' => $siteUrl, - 'site_name' => $siteName, - 'health_token' => $healthToken, - 'action' => 'register', - ], JSON_UNESCAPED_SLASHES); - - $ch = curl_init('https://bench.mokoconsulting.tech/api/waas-heartbeat/register'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-MokoWaaS-Key: moko-waas-hb-2026-x9k4m', - ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - 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 && $code < 300) - { - Factory::getApplication()->enqueueMessage('Grafana heartbeat: site registered', 'message'); - } - } - catch (\Throwable $e) - { - // Silent failure β€” heartbeat is non-critical - } - } - - /** - * One-time migration of params from the monolithic core plugin to - * the new feature plugins. Copies security, tenant, and dev params. - * - * @return void - * - * @since 02.32.00 - */ - private function setupCpanelModule(): void - { - try - { - $db = Factory::getDbo(); - - // Enable the module - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('module')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cpanel')); - $db->setQuery($query); - $db->execute(); - - // Check if a module instance already exists in #__modules - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')); - $db->setQuery($query); - - if ((int) $db->loadResult() > 0) - { - return; - } - - // Create the module instance on the cpanel position - $module = (object) [ - 'title' => 'MokoWaaS', - 'note' => '', - 'content' => '', - 'ordering' => 0, - 'position' => 'top', - 'checked_out' => null, - 'checked_out_time' => null, - 'publish_up' => null, - 'publish_down' => null, - 'published' => 1, - 'module' => 'mod_mokowaas_cpanel', - 'access' => 6, // Super Users only - 'showtitle' => 0, - 'params' => '{"show_health":"1","show_plugins":"1"}', - 'client_id' => 1, // Administrator - 'language' => '*', - ]; - - $db->insertObject('#__modules', $module, 'id'); - $moduleId = (int) $module->id; - - if ($moduleId) - { - // Assign to all admin pages - $map = (object) [ - 'moduleid' => $moduleId, - 'menuid' => 0, // 0 = all pages - ]; - $db->insertObject('#__modules_menu', $map); - } - } - catch (\Throwable $e) - { - Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * One-time migration of params from the monolithic core plugin to - * the new feature plugins. Copies security, tenant, and dev params. - * - * @return void - * - * @since 02.32.00 - */ - private function migrateFeatureParams(): void - { - try - { - $db = Factory::getDbo(); - - // Read core plugin params - $query = $db->getQuery(true) - ->select($db->quoteName('params')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $db->setQuery($query); - $coreParamsJson = (string) $db->loadResult(); - - if (empty($coreParamsJson) || $coreParamsJson === '{}') - { - return; - } - - $core = json_decode($coreParamsJson, true); - - if (empty($core)) - { - return; - } - - // Check migration marker - if (!empty($core['_params_migrated_032'])) - { - return; - } - - // Firewall params - $firewallKeys = [ - 'force_https', 'admin_session_timeout', 'trusted_ips', - 'password_min_length', 'password_require_uppercase', - 'password_require_number', 'password_require_special', - 'upload_allowed_types', 'upload_max_size_mb', - ]; - - // Tenant params - $tenantKeys = [ - 'restrict_installer', 'allow_extension_updates', 'hide_sysinfo', - 'restrict_global_config', 'restrict_template_editing', - 'disable_install_url', 'hidden_menu_items', - ]; - - // DevTools params - $devtoolsKeys = ['dev_mode', 'reset_hits', 'delete_versions']; - - $migrations = [ - 'mokowaas_firewall' => $firewallKeys, - 'mokowaas_tenant' => $tenantKeys, - 'mokowaas_devtools' => $devtoolsKeys, - ]; - - foreach ($migrations as $element => $keys) - { - $featureParams = []; - - foreach ($keys as $key) - { - if (isset($core[$key])) - { - $featureParams[$key] = $core[$key]; - } - } - - if (empty($featureParams)) - { - continue; - } - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($featureParams))) - ->where($db->quoteName('element') . ' = ' . $db->quote($element)) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) - )->execute(); - } - - // Set migration marker on core plugin - $core['_params_migrated_032'] = 1; - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($core))) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) - )->execute(); - - Factory::getApplication()->enqueueMessage( - 'MokoWaaS: migrated settings to feature plugins (Firewall, Tenant, DevTools).', - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } -} diff --git a/updates.xml b/updates.xml deleted file mode 100644 index 22d50ab7..00000000 --- a/updates.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - Package - MokoWaaS - Package - MokoWaaS development build. - pkg_mokowaas - package - site - 02.33.01-dev - 2026-06-04 - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/development - - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/pkg_mokowaas-02.33.01-dev.zip - - 6cf6781c7e76f891470e60e9eaeba28fe28a296f7db3d34fc44ad4e074ec528d - dev - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md - Moko Consulting - https://mokoconsulting.tech - - - - Package - MokoWaaS - Package - MokoWaaS: admin dashboard, security firewall, helpdesk, privacy guard, database tools, and more. - pkg_mokowaas - package - site - 02.33.00 - 2026-06-04 - https://mokoconsulting.tech/products/mokowaas - - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/stable/pkg_mokowaas-02.33.00.zip - - stable - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md - Moko Consulting - https://mokoconsulting.tech - - 8.1 - -