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 0c1049cf..5ea26278 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -9,7 +9,11 @@ Package - MokoWaaS MokoConsulting White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments +<<<<<<< HEAD 02.34.00 +======= + 02.34.16 +>>>>>>> origin/dev GNU General Public License v3 @@ -21,6 +25,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 11dbf65d..8e534df0 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,11 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation +<<<<<<< HEAD # VERSION: 02.34.00 +======= +# VERSION: 02.34.16 +>>>>>>> origin/dev # 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/CHANGELOG.md b/CHANGELOG.md index bc6f7d81..3d8f41c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,62 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./CHANGELOG.md +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 +93,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 +127,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 +142,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 +156,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 ba93de47..506517f0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,11 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 26473101..21d7d9b0 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,11 @@ DEFGROUP: mokoconsulting-tech.MokoWaaSBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand --> diff --git a/LICENSE.md b/LICENSE.md index e8ad3a1b..81f7ef9c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,11 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./LICENSE.md +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 77f02549..7f66b120 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /README.md BRIEF: MokoWaaS platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 52375fc3..9d4dbf4f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,11 @@ DEFGROUP: [PROJECT_NAME] INGROUP: [PROJECT_NAME].Documentation REPO: [REPOSITORY_URL] PATH: /SECURITY.md +<<<<<<< HEAD VERSION: 02.34.00 +======= +VERSION: 02.34.16 +>>>>>>> origin/dev BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index d9adbe28..557188ff 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,21 @@ INGROUP: MokoWaaS.Build REPO: https://github.com/mokoconsulting-tech/mokowaas FILE: build-guide.md +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoWaaS system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> +<<<<<<< HEAD # MokoWaaS Build Guide (VERSION: 02.34.00) +======= +# MokoWaaS Build Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## 1. Purpose @@ -44,7 +52,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 +200,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 8679912b..3658d2c9 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoWaaS system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> +<<<<<<< HEAD # MokoWaaS Configuration Guide (VERSION: 02.34.00) +======= +# MokoWaaS Configuration Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 11c59cd9..52cda101 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoWaaS system plugin NOTE: First document in the guide set --> +<<<<<<< HEAD # MokoWaaS Installation Guide (VERSION: 02.34.00) +======= +# MokoWaaS Installation Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 23c744ae..ec77e9fc 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoWaaS system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> +<<<<<<< HEAD # MokoWaaS Operations Guide (VERSION: 02.34.00) +======= +# MokoWaaS Operations Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 5db4a4ff..a7176845 100644 --- a/docs/guides/rollback-and-recovery-guide.md +++ b/docs/guides/rollback-and-recovery-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 --> +<<<<<<< HEAD # MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.00) +======= +# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 15bf0da1..0ff884e7 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 --> +<<<<<<< HEAD # MokoWaaS Testing Guide (VERSION: 02.34.00) +======= +# MokoWaaS Testing Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## 1. Prerequisites @@ -27,7 +35,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 +286,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 0bcdcef3..b7169ef5 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 --> +<<<<<<< HEAD # MokoWaaS Troubleshooting Guide (VERSION: 02.34.00) +======= +# MokoWaaS Troubleshooting Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index b14777a1..f9e5c834 100644 --- a/docs/guides/upgrade-and-versioning-guide.md +++ b/docs/guides/upgrade-and-versioning-guide.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev 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 --> +<<<<<<< HEAD # MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.00) +======= +# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/index.md b/docs/index.md index bfd4ac24..421ddb86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,21 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoWaaS plugin NOTE: Automatically maintained index for all guide canvases --> +<<<<<<< HEAD # MokoWaaS Documentation Index (VERSION: 02.34.00) +======= +# MokoWaaS Documentation Index (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 4752c5a0..86de9e46 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,20 @@ INGROUP: MokoWaaS REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: /docs/plugin-basic.md +<<<<<<< HEAD VERSION: 02.34.00 +======= + VERSION: 02.34.16 +>>>>>>> origin/dev BRIEF: Baseline documentation for the MokoWaaS system plugin NOTE: Foundational reference for internal and external stakeholders --> +<<<<<<< HEAD # MokoWaaS Plugin Overview (VERSION: 02.34.00) +======= +# MokoWaaS Plugin Overview (VERSION: 02.34.16) +>>>>>>> origin/dev ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 3dd7fa55..ce75089d 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,11 @@ DEFGROUP: MokoWaaS.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoWaaS PATH: /docs/update-server.md +<<<<<<< HEAD VERSION: 02.34.00 +======= +VERSION: 02.34.16 +>>>>>>> origin/dev BRIEF: How this extension's Joomla update server file (update.xml) is managed --> @@ -84,7 +88,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 +140,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 94% rename from src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml rename to source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml index dbe7aede..8d7a359f 100644 --- a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml +++ b/source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml @@ -7,7 +7,11 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml 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 74% rename from src/packages/plg_system_mokowaas/Field/CopyableTokenField.php rename to source/packages/plg_system_mokowaas/Field/CopyableTokenField.php index f35aecf4..c1189f55 100644 --- a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php +++ b/source/packages/plg_system_mokowaas/Field/CopyableTokenField.php @@ -8,7 +8,11 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS +<<<<<<< HEAD:src/packages/plg_system_mokowaas/Field/CopyableTokenField.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.16 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas/Field/CopyableTokenField.php * PATH: /src/Field/CopyableTokenField.php * BRIEF: Read-only token field with a copy-to-clipboard button */ @@ -39,8 +43,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 1e4bbb15..a993acef 100644 --- a/src/packages/plg_system_mokowaas/script.php +++ b/source/packages/plg_system_mokowaas/script.php @@ -22,7 +22,11 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD:src/packages/plg_system_mokowaas/script.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.16 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas/script.php * PATH: /src/script.php * BRIEF: Installation script for MokoWaaS plugin * NOTE: Handles installation, update, and uninstallation tasks including language override deployment @@ -127,7 +131,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $this->ensureMokoCassiopeia(); $this->installLanguageOverrides(); $this->updateLoginSupportUrls(); - $this->updateAtumBranding(); $this->registerActionLogExtension(); $this->provisionHealthEndpoint(); $this->sendInstallNotification($type); @@ -537,80 +540,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 +579,7 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface Folder::create($destDir); } - $pluginOverrides = $this->resolvePlaceholders($this->parseLanguageFile($source)); + $pluginOverrides = $this->parseLanguageFile($source); if (empty($pluginOverrides)) { @@ -696,7 +631,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 +662,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 91% rename from src/packages/plg_system_mokowaas/services/provider.php rename to source/packages/plg_system_mokowaas/services/provider.php index 366257ae..68b08cbe 100644 --- a/src/packages/plg_system_mokowaas/services/provider.php +++ b/source/packages/plg_system_mokowaas/services/provider.php @@ -22,7 +22,11 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas +<<<<<<< HEAD:src/packages/plg_system_mokowaas/services/provider.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.16 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas/services/provider.php * 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 80% rename from src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml rename to source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml index 5a391af0..70b416e4 100644 --- a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml +++ b/source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml @@ -8,7 +8,11 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC Moko\Plugin\System\MokoWaaSDevTools @@ -52,6 +56,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 74% rename from src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml rename to source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml index e68258bc..a9895f56 100644 --- a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml +++ b/source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml @@ -8,7 +8,11 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC Moko\Plugin\System\MokoWaaSFirewall @@ -16,6 +20,7 @@ src sql services + forms language @@ -54,7 +59,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 75% rename from src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml rename to source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml index 6fd5539f..d62ee10c 100644 --- a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml +++ b/source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml @@ -8,7 +8,11 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml PLG_SYSTEM_MOKOWAAS_MONITOR_DESC Moko\Plugin\System\MokoWaaSMonitor @@ -36,6 +40,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 94% rename from src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml rename to source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml index 0b799c8f..289b7b2d 100644 --- a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml +++ b/source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml @@ -8,7 +8,11 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml 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 87% rename from src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml rename to source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml index 68ed90b7..59ddb480 100644 --- a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml +++ b/source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml @@ -12,8 +12,12 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml 02.34.00 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml 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 98% rename from src/packages/plg_system_mokowaas/Service/DemoResetService.php rename to source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php index 44b390f3..2eafd7ef 100644 --- a/src/packages/plg_system_mokowaas/Service/DemoResetService.php +++ b/source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php @@ -10,11 +10,15 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php +<<<<<<< HEAD:src/packages/plg_system_mokowaas/Service/DemoResetService.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.08 +>>>>>>> origin/dev:source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php * 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 86% rename from src/packages/plg_task_mokowaassync/mokowaassync.xml rename to source/packages/plg_task_mokowaassync/mokowaassync.xml index ffc84f56..163c25a6 100644 --- a/src/packages/plg_task_mokowaassync/mokowaassync.xml +++ b/source/packages/plg_task_mokowaassync/mokowaassync.xml @@ -12,7 +12,11 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_task_mokowaassync/mokowaassync.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_task_mokowaassync/mokowaassync.xml 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 98% rename from src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php rename to source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php index 17493bdf..a73a1bbb 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php @@ -10,11 +10,15 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php +<<<<<<< HEAD:src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.08 +>>>>>>> origin/dev:source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php * 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 98% rename from src/packages/plg_system_mokowaas/Service/ContentSyncService.php rename to source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php index 214fdc4f..31c23a56 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php @@ -10,11 +10,15 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php +<<<<<<< HEAD:src/packages/plg_system_mokowaas/Service/ContentSyncService.php * VERSION: 02.34.00 +======= + * VERSION: 02.34.08 +>>>>>>> origin/dev:source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php * 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 82% rename from src/packages/plg_webservices_mokowaas/mokowaas.xml rename to source/packages/plg_webservices_mokowaas/mokowaas.xml index 1c4ae923..d59c8587 100644 --- a/src/packages/plg_webservices_mokowaas/mokowaas.xml +++ b/source/packages/plg_webservices_mokowaas/mokowaas.xml @@ -7,8 +7,12 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_webservices_mokowaas/mokowaas.xml 02.34.00 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_webservices_mokowaas/mokowaas.xml 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 80% rename from src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml rename to source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml index d2fd79c3..d1652a65 100644 --- a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml +++ b/source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml @@ -7,8 +7,12 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech +<<<<<<< HEAD:src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml 02.34.00 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml 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 81% rename from src/packages/plg_webservices_perfectpublisher/services/provider.php rename to source/packages/plg_webservices_perfectpublisher/services/provider.php index 7243384c..12adf9f6 100644 --- a/src/packages/plg_webservices_perfectpublisher/services/provider.php +++ b/source/packages/plg_webservices_perfectpublisher/services/provider.php @@ -7,8 +7,13 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS +<<<<<<< HEAD:src/packages/plg_webservices_perfectpublisher/services/provider.php * PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php * VERSION: 02.34.00 +======= + * PATH: /source/packages/plg_webservices_perfectpublisher/services/provider.php + * VERSION: 02.34.16 +>>>>>>> origin/dev:source/packages/plg_webservices_perfectpublisher/services/provider.php * 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 97% rename from src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php rename to source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php index 3cf972b3..333c4a20 100644 --- a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php +++ b/source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php @@ -7,8 +7,13 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS +<<<<<<< HEAD:src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php * PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php * VERSION: 02.34.00 +======= + * PATH: /source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php + * VERSION: 02.34.16 +>>>>>>> origin/dev:source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php * 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 922babf3..4aceaf2d 100644 --- a/src/pkg_mokowaas.xml +++ b/source/pkg_mokowaas.xml @@ -2,7 +2,11 @@ Package - MokoWaaS mokowaas +<<<<<<< HEAD:src/pkg_mokowaas.xml 02.34.00 +======= + 02.34.15 +>>>>>>> origin/dev:source/pkg_mokowaas.xml 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -10,6 +14,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,18 +23,21 @@ 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 true 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/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/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'); - } - } -}