feat: add admin control panel, feature plugin architecture, and universal workflows
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Update Server / Update Server (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled

- Add admin dashboard to com_mokowaas with site info bar, feature plugin
  grid with AJAX toggles, and quick actions (clear cache, check updates)
- Split monolithic system plugin into 4 toggleable feature plugins:
  Firewall, Tenant Restrictions, DevTools, and Health Monitor
- Add MokoWaaSHelper utility class for shared master-user detection
- Add static updates.xml (licensing system deferred)
- Restore universal moko-platform workflows
- Add param migration in package script for existing sites
- Fix license key warning to show once per session
- Rename license key messages to "Moko Consulting License Key"

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 08:12:26 -05:00
parent 6546592b10
commit 539f1d86fc
52 changed files with 5231 additions and 25 deletions
+2 -2
View File
@@ -94,9 +94,9 @@ sftp-settings.json
replit.md
# ============================================================
# Update server (generated dynamically by MokoGitea)
# Update server (static — committed to repo)
# ============================================================
updates.xml
# updates.xml is now checked in (licensing system deferred)
# ============================================================
# Archives / release artifacts
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoWaaS</display-name>
<org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
<version>02.31.00</version>
<version>02.32.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
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
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
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+270
View File
@@ -0,0 +1,270 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 09.23.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 09.23.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+10
View File
@@ -0,0 +1,10 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
+439
View File
@@ -0,0 +1,439 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/ci-platform.yml
# VERSION: 09.23.00
# BRIEF: moko-platform CI — the standards engine validates itself
#
# +========================================================================+
# | MOKO-PLATFORM CI |
# +========================================================================+
# | |
# | This is NOT a generic CI workflow. This is the self-validation |
# | pipeline for the central moko-platform enterprise engine. |
# | |
# | It dogfoods every tool the platform ships to governed repos: |
# | |
# | Gate 1 — Code Quality phpcs (PSR-12), phpstan (L5), psalm |
# | Gate 2 — Unit Tests phpunit with coverage threshold |
# | Gate 3 — Self-Health bin/moko health against its own repo |
# | Gate 4 — Governance Checks headers, secrets, structure, versions |
# | Gate 5 — Template Lint validate workflow templates parse clean |
# | |
# | If it doesn't pass its own checks, it can't enforce them. |
# | |
# +========================================================================+
name: "Platform: moko-platform CI"
on:
push:
branches:
- main
- dev
- dev/**
- rc/**
paths-ignore:
- '**.md'
- 'wiki/**'
- '.gitea/ISSUE_TEMPLATE/**'
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
inputs:
full_suite:
description: 'Run full validation suite (including slow checks)'
required: false
default: 'true'
type: boolean
concurrency:
group: ci-platform-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
PHP_VERSION: '8.2'
jobs:
# ═══════════════════════════════════════════════════════════════════════
# Gate 1 — Code Quality
# ═══════════════════════════════════════════════════════════════════════
code-quality:
name: "Gate 1: Code Quality"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP ${{ env.PHP_VERSION }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
php -v
- name: Install Composer dependencies
run: |
composer install --no-interaction --prefer-dist
echo "Dependencies installed: $(composer show | wc -l) packages"
- name: "PHP Syntax Check"
run: |
ERRORS=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### PHP Syntax"
echo "Checked ${CHECKED} files — ${ERRORS} error(s)"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || exit 1
- name: "PHPCS (PSR-12)"
run: |
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
echo "::error::PHPCS found coding standard violations"
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
- name: "PHPStan (Level 6)"
run: |
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
echo "::error::PHPStan found type errors"
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
- name: "Psalm"
continue-on-error: true
run: |
if [ -f "psalm.xml" ]; then
vendor/bin/psalm --config=psalm.xml --no-progress --output-format=github 2>&1 || {
echo "### Psalm" >> $GITHUB_STEP_SUMMARY
echo "Psalm found issues (advisory — not blocking)." >> $GITHUB_STEP_SUMMARY
}
fi
# ═══════════════════════════════════════════════════════════════════════
# Gate 2 — Unit Tests
# ═══════════════════════════════════════════════════════════════════════
tests:
name: "Gate 2: Unit Tests"
runs-on: ubuntu-latest
timeout-minutes: 15
needs: code-quality
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
php${{ matrix.php }}-intl composer >/dev/null 2>&1
php -v
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "PHPUnit (PHP ${{ matrix.php }})"
run: |
vendor/bin/phpunit --testdox 2>&1 || {
echo "::error::PHPUnit tests failed"
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Gate 3 — Self-Health (Dogfood)
# ═══════════════════════════════════════════════════════════════════════
self-health:
name: "Gate 3: Self-Health Check"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "Run bin/moko health against self"
run: |
php bin/moko health -- --path . --json > /tmp/health-report.json 2>&1 || true
SCORE=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('percentage', 0))" 2>/dev/null || echo "0")
LEVEL=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('level', 'unknown'))" 2>/dev/null || echo "unknown")
{
echo "### Self-Health Report"
echo ""
echo "| Metric | Value |"
echo "|---|---|"
echo "| Score | ${SCORE}% |"
echo "| Level | ${LEVEL} |"
echo ""
echo "The platform must pass its own health check to enforce it on others."
} >> $GITHUB_STEP_SUMMARY
# Platform must score at least 80%
python3 -c "exit(0 if float('${SCORE}') >= 80.0 else 1)" || {
echo "::error::Self-health score ${SCORE}% is below 80% threshold"
exit 1
}
# ═══════════════════════════════════════════════════════════════════════
# Gate 4 — Governance Checks
# ═══════════════════════════════════════════════════════════════════════
governance:
name: "Gate 4: Governance"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "License headers (SPDX)"
run: |
MISSING=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! head -n 20 "$file" | grep -q "SPDX-License-Identifier:"; then
echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1))
fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### License Headers"
echo "Checked ${CHECKED} files — ${MISSING} missing SPDX headers"
} >> $GITHUB_STEP_SUMMARY
# Advisory — warn but don't fail (yet)
[ "$MISSING" -eq 0 ] || echo "::warning::${MISSING} files missing SPDX license headers"
- name: "Secret detection"
run: |
FOUND=0
# Check for common secret patterns in source files
while IFS= read -r -d '' file; do
if grep -qEi '(password|secret|token|apikey|api_key)\s*[:=]\s*["\x27][^\s]{8,}' "$file" 2>/dev/null; then
echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1))
fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### Secret Detection"
if [ "$FOUND" -eq 0 ]; then
echo "No hardcoded secrets detected."
else
echo "${FOUND} potential secrets found."
fi
} >> $GITHUB_STEP_SUMMARY
[ "$FOUND" -eq 0 ] || exit 1
- name: "Version consistency"
run: |
# Extract version from composer.json
COMPOSER_VER=$(python3 -c "import json; print(json.load(open('composer.json'))['version'])")
# Extract version from README.md
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
{
echo "### Version Consistency"
echo "| Source | Version |"
echo "|---|---|"
echo "| composer.json | ${COMPOSER_VER} |"
echo "| README.md | ${README_VER:-not found} |"
} >> $GITHUB_STEP_SUMMARY
if [ -n "$README_VER" ] && [ "$COMPOSER_VER" != "$README_VER" ]; then
echo "::warning::Version mismatch: composer.json=${COMPOSER_VER} vs README.md=${README_VER}"
fi
# ═══════════════════════════════════════════════════════════════════════
# Gate 5 — Template Integrity
# ═══════════════════════════════════════════════════════════════════════
templates:
name: "Gate 5: Template Integrity"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
if: github.event_name != 'push' || github.event.inputs.full_suite != 'false'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: "Validate workflow templates"
run: |
ERRORS=0
CHECKED=0
# Check all YAML workflow templates parse cleanly
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
echo "::error file=${file}::Invalid YAML"
ERRORS=$((ERRORS + 1))
fi
done < <(find templates/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
# Also check the live workflows
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
echo "::error file=${file}::Invalid YAML"
ERRORS=$((ERRORS + 1))
fi
done < <(find .mokogitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
{
echo "### Template Integrity"
echo "Validated ${CHECKED} YAML files — ${ERRORS} parse errors"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || exit 1
- name: "Validate gitignore templates"
run: |
TEMPLATES=0
for GI in templates/configs/gitignore templates/configs/gitignore.dolibarr templates/configs/.gitignore.joomla; do
if [ -f "$GI" ]; then
TEMPLATES=$((TEMPLATES + 1))
# Verify required entries
for REQUIRED in ".claude/" "TODO.md" "*.min.css" "*.min.js" "wiki/"; do
if ! grep -q "$REQUIRED" "$GI"; then
echo "::error file=${GI}::Missing required entry: ${REQUIRED}"
fi
done
fi
done
echo "### Gitignore Templates" >> $GITHUB_STEP_SUMMARY
echo "Validated ${TEMPLATES} gitignore templates." >> $GITHUB_STEP_SUMMARY
- name: "Validate PHP validation scripts"
run: |
ERRORS=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::Validation script has syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find validate/ -name "*.php" -print0 2>/dev/null)
{
echo "### Validation Scripts"
echo "Checked ${CHECKED} scripts — ${ERRORS} syntax errors"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || { echo "::error::Validation scripts must be error-free"; exit 1; }
# ═══════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════
summary:
name: "CI Summary"
runs-on: ubuntu-latest
needs: [code-quality, tests, self-health, governance, templates]
if: always()
steps:
- name: Check gate results
run: |
{
echo "# moko-platform CI"
echo ""
echo "| Gate | Job | Status |"
echo "|---|---|---|"
echo "| 1 | Code Quality | ${{ needs.code-quality.result }} |"
echo "| 2 | Unit Tests | ${{ needs.tests.result }} |"
echo "| 3 | Self-Health | ${{ needs.self-health.result }} |"
echo "| 4 | Governance | ${{ needs.governance.result }} |"
echo "| 5 | Templates | ${{ needs.templates.result }} |"
echo ""
echo "> *The standards engine must pass its own standards.*"
} >> $GITHUB_STEP_SUMMARY
# Fail if any required gate failed
if [ "${{ needs.code-quality.result }}" = "failure" ] || \
[ "${{ needs.tests.result }}" = "failure" ] || \
[ "${{ needs.self-health.result }}" = "failure" ] || \
[ "${{ needs.governance.result }}" = "failure" ] || \
[ "${{ needs.templates.result }}" = "failure" ]; then
echo "::error::One or more CI gates failed"
exit 1
fi
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 09.23.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup"
on:
schedule:
- cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
workflow_dispatch:
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
name: Clean Merged Branches
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|develop|release/*|hotfix/*) continue ;;
esac
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
done
echo "Deleted ${DELETED} merged branch(es)"
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
echo "Deleted ${DELETED} old workflow run(s)"
+96
View File
@@ -0,0 +1,96 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 09.23.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 09.23.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml
# VERSION: 09.23.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
types:
- completed
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
jobs:
notify:
name: Send Notification
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
- name: Notify on success (releases only)
if: >-
github.event.workflow_run.conclusion == 'success' &&
contains(github.event.workflow_run.name, 'Release')
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} released" \
-H "Tags: white_check_mark,package" \
-H "Priority: default" \
-H "Click: ${URL}" \
-d "${WORKFLOW} completed successfully." \
"${NTFY_URL}/${NTFY_TOPIC}"
- name: Notify on failure
if: github.event.workflow_run.conclusion == 'failure'
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} workflow failed" \
-H "Tags: x,warning" \
-H "Priority: high" \
-H "Click: ${URL}" \
-d "${WORKFLOW} failed. Check the run for details." \
"${NTFY_URL}/${NTFY_TOPIC}"
+236
View File
@@ -0,0 +1,236 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+224
View File
@@ -0,0 +1,224 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 09.23.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
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 }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
- 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
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: |
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
release-candidate) TAG="release-candidate" ;;
esac
# Set stability suffix, bump preserves it, fix consistency
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Commit version bump
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"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
- name: Create release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push
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
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
+769
View File
@@ -0,0 +1,769 @@
# ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Generic: Repo Health"
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, release, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
push:
permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .mokogitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
missing_required=()
missing_optional=()
# Source directory: src/ or htdocs/ (either is valid for extension repos)
SOURCE_DIR=""
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
# Platform/tooling repos don't need src/
SOURCE_DIR=""
else
missing_required+=("src/ or htdocs/ (source directory required)")
fi
for item in "${required_artifacts[@]}"; do
if printf '%s' "${item}" | grep -q '/$'; then
d="${item%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
missing_required+=("dev or dev/* branch")
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
if [ -n "${SOURCE_DIR}" ]; then
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
fi
done
fi
if [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links=""
while IFS= read -r docline; do
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
linkpath="${link%%#*}"
linkpath="${linkpath%%\?*}"
[ -z "$linkpath" ] && continue
if [ "${linkpath:0:1}" = "/" ]; then
testpath="${linkpath#/}"
else
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
fi
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
done
done < "${DOCS_INDEX}"
if [ -n "${missing_links}" ]; then
extended_findings+=("docs/docs-index.md contains broken relative links")
{
printf '%s\n' '### Docs index link integrity'
printf '%s\n' 'Broken relative links:'
for bl in ${missing_links}; do
printf '%s\n' "- ${bl}"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
site-health:
name: Site Health
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Uptime check
if: env.URLS != ''
run: |
echo "$URLS" > /tmp/urls.txt
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
rm -f /tmp/urls.txt
env:
URLS: ${{ vars.MONITORED_URLS }}
- name: SSL certificate check
if: env.DOMAINS != ''
run: |
echo "$DOMAINS" > /tmp/domains.txt
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
rm -f /tmp/domains.txt
env:
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
- name: Summary
if: always()
run: |
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
+98
View File
@@ -0,0 +1,98 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 09.23.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+302
View File
@@ -0,0 +1,302 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 09.23.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Gitea release tag per stability
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
rc) TAG="release-candidate" ;;
*) TAG="stable" ;;
esac
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${VERSION}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
+24
View File
@@ -19,6 +19,30 @@
-->
# Changelog
## [02.32.00] - 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
- plg_system_mokowaas_firewall — HTTPS enforcement, trusted IPs, session timeout, upload restrictions, password policy
- plg_system_mokowaas_tenant — Installer, sysinfo, config, template, and menu restrictions for non-master users
- plg_system_mokowaas_devtools — Dev mode, hit counter reset, content version cleanup
- plg_system_mokowaas_monitor — Grafana heartbeat integration and health monitoring
- MokoWaaSHelper utility class for shared master-user detection across feature plugins
- AJAX plugin toggle — enable/disable feature plugins directly from the dashboard
- Clear cache quick action on dashboard
- Static updates.xml for update server (licensing system deferred)
- Automatic param migration from core plugin to feature plugins on upgrade
### Changed
- com_mokowaas upgraded from API-only to full admin component with dashboard views
- Package manifest updated with 4 new feature plugin entries (10 extensions total)
- Update server URL changed to static raw file endpoint
- Core plugin slimmed — security, tenant, devtools, and monitor features extracted to dedicated plugins
### Removed
- 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
### Added
- License key support via Joomla's native Update Sites download key system (dlid)
@@ -0,0 +1,18 @@
; 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."
@@ -0,0 +1,7 @@
; 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"
@@ -0,0 +1,91 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
public function display($cachable = false, $urlparams = [])
{
return parent::display($cachable, $urlparams);
}
/**
* Toggle a MokoWaaS feature plugin on or off.
*
* Expects POST with extension_id and enabled (0 or 1).
* Returns JSON response for AJAX calls.
*/
public function togglePlugin()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->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();
}
}
@@ -0,0 +1,305 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\Version;
class DashboardModel extends BaseDatabaseModel
{
/**
* Feature plugin metadata keyed by element name.
* Provides icon, category, and description for dashboard display.
*/
private const PLUGIN_META = [
'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,
],
'mokowaas_firewall' => [
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy.',
'protected' => 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,
],
'mokowaas_devtools' => [
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup.',
'protected' => false,
],
'mokowaas_monitor' => [
'icon' => 'icon-heartbeat',
'category' => 'monitoring',
'label' => 'Health Monitor',
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
'protected' => false,
],
];
/**
* Category display labels and colours.
*/
private const CATEGORIES = [
'core' => ['label' => 'Core', 'badge' => 'bg-dark'],
'security' => ['label' => 'Security', 'badge' => 'bg-danger'],
'tools' => ['label' => 'Tools', 'badge' => 'bg-info'],
'monitoring' => ['label' => 'Monitoring', 'badge' => 'bg-success'],
'content' => ['label' => 'Content', 'badge' => 'bg-primary'],
'api' => ['label' => 'API', 'badge' => 'bg-secondary'],
];
/**
* Discover all installed MokoWaaS plugins.
*
* @return array Plugin rows enriched with dashboard metadata.
*/
public function getFeaturePlugins(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('extension_id'),
$db->quoteName('name'),
$db->quoteName('element'),
$db->quoteName('folder'),
$db->quoteName('type'),
$db->quoteName('enabled'),
$db->quoteName('protected'),
$db->quoteName('params'),
$db->quoteName('manifest_cache'),
])
->from($db->quoteName('#__extensions'))
->where([
'(' .
// System plugins: mokowaas, mokowaas_*
'(' . $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\_%') . '))'
// Webservices plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') . ')'
// Task plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('task')
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas%') . ')'
. ')',
])
->order($db->quoteName('folder') . ' ASC, ' . $db->quoteName('element') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$plugins = [];
foreach ($rows as $row)
{
$manifest = json_decode($row->manifest_cache ?? '{}');
$version = $manifest->version ?? '';
// Build a lookup key: system plugins use element, others use folder_element
$metaKey = $row->element;
$meta = self::PLUGIN_META[$metaKey] ?? null;
// Auto-generate meta for task/webservices plugins not in the map
if (!$meta)
{
$meta = $this->guessPluginMeta($row);
}
$categoryKey = $meta['category'] ?? 'tools';
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
$plugins[] = (object) [
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'categoryLabel' => $categoryInfo['label'],
'categoryBadge' => $categoryInfo['badge'],
'description' => $meta['description'] ?? '',
];
}
return $plugins;
}
/**
* Get basic site information for the info bar.
*
* @return object
*/
public function getSiteInfo(): object
{
$app = Factory::getApplication();
$config = $app->getConfig();
$db = $this->getDatabase();
// Get MokoWaaS package version
$query = $db->getQuery(true)
->select($db->quoteName('manifest_cache'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
$db->setQuery($query);
$pkgCache = json_decode($db->loadResult() ?? '{}');
return (object) [
'sitename' => $config->get('sitename', ''),
'joomla_version' => (new Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'db_type' => $db->getServerType(),
'mokowaas_version' => $pkgCache->version ?? '—',
'debug' => (bool) $config->get('debug'),
'offline' => (bool) $config->get('offline'),
'sef' => (bool) $config->get('sef'),
'caching' => (int) $config->get('caching'),
];
}
/**
* Toggle a plugin's enabled state.
*
* @param int $extensionId The extension ID.
* @param int $enabled 1 = enable, 0 = disable.
*
* @return array Result with success and message keys.
*/
public function togglePlugin(int $extensionId, int $enabled): array
{
$db = $this->getDatabase();
// Verify the extension exists and is a MokoWaaS plugin
$query = $db->getQuery(true)
->select([$db->quoteName('element'), $db->quoteName('protected')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
$db->setQuery($query);
$ext = $db->loadObject();
if (!$ext)
{
return ['success' => false, 'message' => 'Extension not found.'];
}
// Don't allow disabling protected/core plugins
if (!$enabled && ((int) $ext->protected || $ext->element === 'mokowaas'))
{
return ['success' => false, 'message' => 'This plugin is protected and cannot be disabled.'];
}
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = ' . ($enabled ? 1 : 0))
->where($db->quoteName('extension_id') . ' = ' . $extensionId);
$db->setQuery($query);
$db->execute();
return [
'success' => true,
'message' => $ext->element . ($enabled ? ' enabled.' : ' disabled.'),
'enabled' => $enabled,
];
}
/**
* Clear all Joomla caches.
*
* @return array Result with success and message keys.
*/
public function clearCache(): array
{
try
{
$app = Factory::getApplication();
$app->get('cache_handler', 'file');
// Clear site and admin caches
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
Factory::getCache('', '')->gc();
Factory::getCache('', '', 'administrator')->gc();
// Clear opcache if available
if (\function_exists('opcache_reset'))
{
\opcache_reset();
}
return ['success' => true, 'message' => 'Cache cleared successfully.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Cache clear failed: ' . $e->getMessage()];
}
}
/**
* Auto-generate dashboard metadata for plugins not in the static map.
*/
private function guessPluginMeta(object $row): array
{
$meta = [
'icon' => 'icon-puzzle-piece',
'category' => 'tools',
'label' => $row->name,
'description' => '',
'protected' => false,
];
if ($row->folder === 'webservices')
{
$meta['icon'] = 'icon-plug';
$meta['category'] = 'api';
$meta['label'] = 'Web Services — ' . ucfirst($row->element);
}
elseif ($row->folder === 'task')
{
$meta['icon'] = 'icon-clock';
$meta['category'] = 'content';
if (str_contains($row->element, 'sync'))
{
$meta['label'] = 'Content Sync Task';
$meta['description'] = 'Scheduled content synchronisation to remote MokoWaaS sites.';
}
elseif (str_contains($row->element, 'demo'))
{
$meta['label'] = 'Demo Reset Task';
$meta['description'] = 'Scheduled demo site reset with content snapshots.';
}
}
return $meta;
}
}
@@ -0,0 +1,58 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/**
* @var array Discovered MokoWaaS feature plugins.
*/
protected $plugins = [];
/**
* @var object Site info (Joomla version, PHP version, etc.).
*/
protected $siteInfo;
public function display($tpl = null)
{
$model = $this->getModel();
$this->plugins = $model->getFeaturePlugins();
$this->siteInfo = $model->getSiteInfo();
$this->addToolbar();
// Load dashboard assets
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
$wa->registerAndUseScript('com_mokowaas.dashboard', 'com_mokowaas/dashboard.js', [], ['defer' => true]);
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_DASHBOARD_TITLE'), 'cogs');
$user = Factory::getApplication()->getIdentity();
if ($user->authorise('core.admin', 'com_mokowaas'))
{
ToolbarHelper::preferences('com_mokowaas');
}
}
}
@@ -0,0 +1,154 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var \Moko\Component\MokoWaaS\Administrator\View\Dashboard\HtmlView $this */
$siteInfo = $this->siteInfo;
$plugins = $this->plugins;
$token = Session::getFormToken();
// Group plugins by category
$grouped = [];
foreach ($plugins as $plugin)
{
$grouped[$plugin->category][] = $plugin;
}
// Category display order
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
?>
<div id="mokowaas-dashboard">
<!-- Site Info Bar -->
<div class="mokowaas-info-bar card mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-4">
<div class="mokowaas-info-item">
<span class="mokowaas-info-label"><?php echo Text::_('COM_MOKOWAAS_SITE'); ?></span>
<span class="mokowaas-info-value fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
</div>
<div class="mokowaas-info-item">
<span class="mokowaas-info-label">MokoWaaS</span>
<span class="mokowaas-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokowaas_version); ?></span></span>
</div>
<div class="mokowaas-info-item">
<span class="mokowaas-info-label">Joomla</span>
<span class="mokowaas-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span>
</div>
<div class="mokowaas-info-item">
<span class="mokowaas-info-label">PHP</span>
<span class="mokowaas-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->php_version); ?></span></span>
</div>
<div class="mokowaas-info-item">
<span class="mokowaas-info-label"><?php echo Text::_('COM_MOKOWAAS_DATABASE'); ?></span>
<span class="mokowaas-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span></span>
</div>
<?php if ($siteInfo->debug): ?>
<div class="mokowaas-info-item">
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOWAAS_DEBUG_ON'); ?></span>
</div>
<?php endif; ?>
<?php if ($siteInfo->offline): ?>
<div class="mokowaas-info-item">
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
</div>
<?php endif; ?>
</div>
</div>
<!-- Quick Actions -->
<div class="mokowaas-quick-actions mb-4">
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="mokowaas-btn-cache"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
</button>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary btn-sm">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
</a>
</div>
</div>
<!-- Feature Plugin Grid -->
<?php foreach ($categoryOrder as $catKey): ?>
<?php if (empty($grouped[$catKey])) continue; ?>
<?php
$catPlugins = $grouped[$catKey];
$first = $catPlugins[0];
?>
<h3 class="mokowaas-category-heading mb-3">
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
</h3>
<div class="mokowaas-plugin-grid row g-3 mb-4">
<?php foreach ($catPlugins as $plugin): ?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card mokowaas-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokowaas-plugin-disabled'; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="<?php echo $this->escape($plugin->icon); ?> mokowaas-plugin-icon" aria-hidden="true"></span>
<h5 class="card-title mb-0"><?php echo $this->escape($plugin->name); ?></h5>
</div>
<?php if ($plugin->version): ?>
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
<?php endif; ?>
</div>
<p class="card-text text-muted small flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<div class="d-flex align-items-center gap-2">
<?php if ($plugin->protected): ?>
<span class="badge bg-dark" title="<?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?>"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
<?php else: ?>
<div class="form-check form-switch">
<input
type="checkbox"
class="form-check-input mokowaas-toggle"
role="switch"
id="toggle-<?php echo $plugin->extension_id; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.togglePlugin&format=json'); ?>"
data-token="<?php echo $token; ?>"
<?php echo $plugin->enabled ? 'checked' : ''; ?>
>
<label class="form-check-label small" for="toggle-<?php echo $plugin->extension_id; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</label>
</div>
<?php endif; ?>
</div>
<?php
// Build configure link
$configUrl = '';
if ($plugin->type === 'plugin')
{
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id);
}
?>
<?php if ($configUrl): ?>
<a href="<?php echo $configUrl; ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CONFIGURE'); ?>
</a>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
@@ -0,0 +1,94 @@
/**
* MokoWaaS Dashboard Styles
* @package com_mokowaas
*/
/* Info bar */
.mokowaas-info-bar .card-body {
padding: 1rem 1.5rem;
}
.mokowaas-info-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mokowaas-info-label {
font-size: 0.8125rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.mokowaas-info-value {
font-size: 0.875rem;
}
/* Plugin cards */
.mokowaas-plugin-card {
transition: box-shadow 0.15s ease, opacity 0.15s ease;
border-left: 3px solid #0d6efd;
}
.mokowaas-plugin-card:hover {
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
}
.mokowaas-plugin-disabled {
opacity: 0.6;
border-left-color: #adb5bd;
}
.mokowaas-plugin-disabled:hover {
opacity: 0.8;
}
.mokowaas-plugin-icon {
font-size: 1.5rem;
color: #1a2744;
width: 2rem;
text-align: center;
}
/* Category headings */
.mokowaas-category-heading {
font-size: 1rem;
font-weight: 600;
padding-top: 0.5rem;
}
/* Toggle switch */
.mokowaas-toggle {
cursor: pointer;
}
.mokowaas-toggle:disabled {
cursor: not-allowed;
}
/* Quick actions */
.mokowaas-quick-actions .btn {
transition: all 0.15s ease;
}
.mokowaas-quick-actions .btn:disabled {
pointer-events: none;
}
/* Loading spinner overlay on toggle */
.mokowaas-plugin-card.is-loading {
position: relative;
pointer-events: none;
}
.mokowaas-plugin-card.is-loading::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
}
@@ -0,0 +1,112 @@
/**
* MokoWaaS Dashboard Scripts
* @package com_mokowaas
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
// Plugin toggle switches
document.querySelectorAll('.mokowaas-toggle').forEach(function (toggle) {
toggle.addEventListener('change', function () {
var checkbox = this;
var card = checkbox.closest('.mokowaas-plugin-card');
var extensionId = checkbox.dataset.extensionId;
var url = checkbox.dataset.url;
var token = checkbox.dataset.token;
var enabled = checkbox.checked ? 1 : 0;
var label = card.querySelector('.form-check-label');
card.classList.add('is-loading');
checkbox.disabled = true;
var formData = new FormData();
formData.append('extension_id', extensionId);
formData.append('enabled', enabled);
formData.append(token, '1');
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.success) {
card.classList.toggle('mokowaas-plugin-disabled', !enabled);
if (label) {
label.textContent = enabled
? Joomla.Text._('COM_MOKOWAAS_ENABLED') || 'Enabled'
: Joomla.Text._('COM_MOKOWAAS_DISABLED') || 'Disabled';
}
} else {
// Revert on failure
checkbox.checked = !checkbox.checked;
Joomla.renderMessages({error: [data.message || 'Toggle failed.']});
}
})
.catch(function () {
checkbox.checked = !checkbox.checked;
Joomla.renderMessages({error: ['Network error. Please try again.']});
})
.finally(function () {
card.classList.remove('is-loading');
checkbox.disabled = false;
});
});
});
// Clear cache button
var cacheBtn = document.getElementById('mokowaas-btn-cache');
if (cacheBtn) {
cacheBtn.addEventListener('click', function () {
var btn = this;
var url = btn.dataset.url;
var token = btn.dataset.token;
btn.disabled = true;
var btnIcon = btn.querySelector('span');
var btnOriginalClass = btnIcon ? btnIcon.className : '';
if (btnIcon) {
btnIcon.className = 'icon-spinner icon-spin';
}
btn.childNodes.forEach(function (n) {
if (n.nodeType === Node.TEXT_NODE) n.textContent = ' Clearing...';
});
var formData = new FormData();
formData.append(token, '1');
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.success) {
Joomla.renderMessages({message: [data.message || 'Cache cleared.']});
} else {
Joomla.renderMessages({error: [data.message || 'Cache clear failed.']});
}
})
.catch(function () {
Joomla.renderMessages({error: ['Network error. Please try again.']});
})
.finally(function () {
btn.disabled = false;
var icon = btn.querySelector('span');
if (icon) {
icon.className = btnOriginalClass;
}
btn.childNodes.forEach(function (n) {
if (n.nodeType === Node.TEXT_NODE) n.textContent = ' Clear Cache';
});
});
});
}
});
+30 -5
View File
@@ -1,24 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: Joomla.Component
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.32.00
PATH: /mokowaas.xml
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
-->
<extension type="component" method="upgrade">
<name>MokoWaaS API</name>
<name>MokoWaaS</name>
<author>Moko Consulting</author>
<creationDate>2026-05-23</creationDate>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.31.00</version>
<version>02.31.00</version>
<description>Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups.</description>
<version>02.32.00</version>
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="admin/src">Moko\Component\MokoWaaS\Administrator</namespace>
<namespace path="api/src">Moko\Component\MokoWaaS\Api</namespace>
<administration>
<menu img="class:cogs">MokoWaaS</menu>
<files folder="admin">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
</administration>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<media destination="com_mokowaas" folder="media">
<folder>css</folder>
<folder>js</folder>
</media>
</extension>
@@ -4350,6 +4350,16 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
// Only warn once per session
$session = Factory::getSession();
if ($session->get('mokowaas.license_warned', false))
{
return;
}
$session->set('mokowaas.license_warned', true);
try
{
$db = Factory::getDbo();
@@ -4366,10 +4376,10 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Required</strong> — '
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key (MOKO-XXXX-XXXX-XXXX-XXXX) in the Download Key field for the MokoWaaS update site.',
. 'and enter your license key in the Download Key field for the MokoWaaS update site.',
'warning'
);
@@ -4396,7 +4406,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
if ($session->get('mokowaas.license_invalid', false))
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Invalid</strong> — '
'<strong>Moko Consulting License Key Invalid</strong> — '
. 'Your license key could not be validated. Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
'error'
@@ -4430,7 +4440,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
if (!$isValid)
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Invalid</strong> — '
'<strong>Moko Consulting License Key Invalid</strong> — '
. 'Your license key could not be validated. Updates will not be available. '
. 'Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
@@ -0,0 +1,96 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaS\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Registry\Registry;
/**
* Shared utility class for MokoWaaS feature plugins.
*
* Provides master-user detection and core plugin parameter access
* so that feature plugins do not need to duplicate obfuscated constants.
*
* @since 02.32.00
*/
final class MokoWaaSHelper
{
private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0='];
private const MK = 0x5A;
/** @var array|null Decoded master usernames cache. */
private static ?array $masterNames = null;
/**
* Check whether the current user is a master user.
*
* @return bool
*/
public static function isMasterUser(): bool
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest)
{
return false;
}
return \in_array($user->username, self::getMasterUsernames(), true);
}
/**
* Get the decoded list of master usernames.
*
* @return array
*/
public static function getMasterUsernames(): array
{
if (self::$masterNames !== null)
{
return self::$masterNames;
}
self::$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);
}
self::$masterNames[] = $decoded;
}
return self::$masterNames;
}
/**
* Get the core system plugin parameters as a Registry.
*
* @return Registry
*/
public static function getCoreParams(): Registry
{
$plugin = PluginHelper::getPlugin('system', 'mokowaas');
if (!$plugin)
{
return new Registry();
}
return new Registry($plugin->params ?? '{}');
}
}
@@ -30,8 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.31.00</version>
<version>02.31.00</version>
<version>02.32.00</version>
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
<scriptfile>script.php</scriptfile>
@@ -40,6 +39,7 @@
<filename plugin="mokowaas">script.php</filename>
<folder>Extension</folder>
<folder>Field</folder>
<folder>Helper</folder>
<folder>Service</folder>
<folder>forms</folder>
<folder>payload</folder>
@@ -0,0 +1,15 @@
; MokoWaaS Developer Tools Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOWAAS_DEVTOOLS="System - MokoWaaS DevTools"
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC="Development mode, hit counter reset, and content version cleanup."
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_FIELDSET_BASIC="Developer Tools"
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_FIELDSET_BASIC_DESC="Development and maintenance toggles."
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DEV_MODE_LABEL="Development Mode"
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DEV_MODE_DESC="Disables caching, enables debug, suppresses hit recording, shows offline on primary domain."
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."
@@ -0,0 +1,3 @@
; MokoWaaS Developer Tools Plugin - System strings
PLG_SYSTEM_MOKOWAAS_DEVTOOLS="System - MokoWaaS DevTools"
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC="Development mode, hit counter reset, and content version cleanup."
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS DevTools</name>
<element>mokowaas_devtools</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.00</version>
<description>PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSDevTools</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas_devtools.ini</language>
<language tag="en-GB">en-GB/plg_system_mokowaas_devtools.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_FIELDSET_BASIC_DESC">
<field name="dev_mode" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DEV_MODE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DEV_MODE_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="reset_hits" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_HITS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_RESET_HITS_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="delete_versions" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DELETE_VERSIONS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DELETE_VERSIONS_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_devtools
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoWaaSDevTools\Extension\DevTools;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new DevTools($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_devtools'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,155 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_devtools
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSDevTools\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
/**
* MokoWaaS Developer Tools Plugin
*
* Provides development mode (disables caching, enables debug), hit counter
* reset, and content version cleanup.
*
* @since 02.32.00
*/
class DevTools extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterInitialise' => 'onAfterInitialise',
'onExtensionAfterSave' => 'onExtensionAfterSave',
];
}
/**
* Apply dev mode settings at runtime.
*/
public function onAfterInitialise(): void
{
if (!$this->params->get('dev_mode', 0))
{
return;
}
$config = Factory::getConfig();
$config->set('caching', 0);
$config->set('debug', 1);
// Show offline page on primary domain
$primaryDomain = $this->params->get('primary_domain', '');
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if (!empty($primaryDomain) && $currentHost === $primaryDomain)
{
$config->set('offline', 1);
}
// Suppress hit recording
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
->where($db->quoteName('hits') . ' > 0')
)->execute();
}
catch (\Throwable $e)
{
// Silent
}
}
/**
* Handle maintenance actions when this plugin's params are saved.
*/
public function onExtensionAfterSave($event): void
{
$context = $event->getArgument(0, '');
$table = $event->getArgument(1);
$isNew = $event->getArgument(2, false);
if ($context !== 'com_plugins.plugin' || !$table)
{
return;
}
// Only process saves to this plugin
if (($table->element ?? '') !== 'mokowaas_devtools' || ($table->folder ?? '') !== 'system')
{
return;
}
$params = new \Joomla\Registry\Registry($table->params ?? '{}');
// Reset hits on save if toggled on
if ($params->get('reset_hits', 0))
{
$this->resetAllHits();
$params->set('reset_hits', 0);
}
// Delete versions on save if toggled on
if ($params->get('delete_versions', 0))
{
$this->deleteAllVersions();
$params->set('delete_versions', 0);
}
// Reset the one-shot toggles
if ($table->params !== $params->toString())
{
$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)
)->execute();
}
}
private function resetAllHits(): int
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
->where($db->quoteName('hits') . ' > 0')
)->execute();
$count = $db->getAffectedRows();
$this->getApplication()->enqueueMessage(\sprintf('Reset hits on %d articles.', $count), 'message');
return $count;
}
private function deleteAllVersions(): int
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)->delete($db->quoteName('#__history'))
)->execute();
$count = $db->getAffectedRows();
$this->getApplication()->enqueueMessage(\sprintf('Deleted %d version history records.', $count), 'message');
return $count;
}
}
@@ -0,0 +1,30 @@
; MokoWaaS Firewall Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOWAAS_FIREWALL="System - MokoWaaS Firewall"
PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC="HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy."
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC="Network &amp; Session"
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC_DESC="HTTPS, session timeout, and trusted IP settings."
PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_LABEL="Force HTTPS"
PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_DESC="Redirect all HTTP requests to HTTPS. Recommended for production sites."
PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_LABEL="Admin Session Timeout (minutes)"
PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_DESC="Idle timeout in minutes for admin sessions. 0 = use Joomla default. Master users and trusted IPs are exempt."
PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_LABEL="Trusted IPs"
PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_DESC="IP addresses or CIDR blocks that bypass session timeout. Supports exact IPs, CIDR (10.0.0.0/8), and wildcards (192.168.1.*)."
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD="Password Policy"
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD_DESC="Minimum password complexity requirements for all users."
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_LABEL="Minimum Password Length"
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_DESC="Minimum number of characters required."
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_UPPER_LABEL="Require Uppercase"
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_NUMBER_LABEL="Require Number"
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_SPECIAL_LABEL="Require Special Character"
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS="Upload Restrictions"
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS_DESC="Override Joomla's upload settings at runtime."
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_LABEL="Allowed File Types"
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_DESC="Comma-separated list of permitted file extensions."
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_LABEL="Max Upload Size (MB)"
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes."
@@ -0,0 +1,3 @@
; MokoWaaS Firewall Plugin - System strings
PLG_SYSTEM_MOKOWAAS_FIREWALL="System - MokoWaaS Firewall"
PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC="HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy."
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS Firewall</name>
<element>mokowaas_firewall</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.00</version>
<description>PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSFirewall</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas_firewall.ini</language>
<language tag="en-GB">en-GB/plg_system_mokowaas_firewall.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC_DESC">
<field name="force_https" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="admin_session_timeout" type="number"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_DESC"
default="60" hint="Minutes (0 = Joomla default)" />
<field name="trusted_ips" type="subform"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_DESC"
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
multiple="true"
layout="joomla.form.field.subform.repeatable-table"
groupByFieldset="false"
buttons="add,remove,move" />
</fieldset>
<fieldset name="password_policy"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD_DESC">
<field name="password_min_length" type="number" default="12"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_DESC" />
<field name="password_require_uppercase" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_UPPER_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="password_require_number" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_NUMBER_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="password_require_special" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_SPECIAL_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="uploads"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS_DESC">
<field name="upload_allowed_types" type="text"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_DESC"
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
<field name="upload_max_size_mb" type="number"
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_DESC"
default="100" />
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_firewall
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoWaaSFirewall\Extension\Firewall;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Firewall($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_firewall'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,259 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_firewall
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSFirewall\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
/**
* MokoWaaS Firewall Plugin
*
* Provides HTTPS enforcement, trusted IP management, admin session timeout,
* upload restrictions, and password policy enforcement.
*
* @since 02.32.00
*/
class Firewall extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterInitialise' => 'onAfterInitialise',
'onUserBeforeSave' => 'onUserBeforeSave',
];
}
public function onAfterInitialise(): void
{
$this->enforceHttps();
$this->enforceUploadRestrictions();
if ($this->getApplication()->isClient('administrator'))
{
$this->enforceAdminSessionTimeout();
}
}
/**
* Enforce password complexity rules before user save.
*/
public function onUserBeforeSave($event): void
{
$oldUser = $event[0] ?? $event->getArgument(0, []);
$isNew = $event[1] ?? $event->getArgument(1, false);
$newUser = $event[2] ?? $event->getArgument(2, []);
if (empty($newUser['password_clear']))
{
return;
}
$password = $newUser['password_clear'];
$errors = [];
$minLen = (int) $this->params->get('password_min_length', 12);
if (\strlen($password) < $minLen)
{
$errors[] = \sprintf('Password must be at least %d characters.', $minLen);
}
if ($this->params->get('password_require_uppercase', 1) && !preg_match('/[A-Z]/', $password))
{
$errors[] = 'Password must contain an uppercase letter.';
}
if ($this->params->get('password_require_number', 1) && !preg_match('/\d/', $password))
{
$errors[] = 'Password must contain a number.';
}
if ($this->params->get('password_require_special', 1) && !preg_match('/[^A-Za-z0-9]/', $password))
{
$errors[] = 'Password must contain a special character.';
}
if (!empty($errors))
{
throw new \RuntimeException(implode(' ', $errors));
}
}
/**
* Redirect non-HTTPS requests to HTTPS.
*/
private function enforceHttps(): void
{
if (!$this->params->get('force_https', 0))
{
return;
}
$app = $this->getApplication();
if ($app->isClient('cli'))
{
return;
}
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
if (!$isHttps)
{
$app->redirect('https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], 301);
}
}
/**
* Enforce admin session idle timeout.
*/
private function enforceAdminSessionTimeout(): void
{
$timeout = (int) $this->params->get('admin_session_timeout', 0);
if ($timeout <= 0)
{
return;
}
if (MokoWaaSHelper::isMasterUser())
{
return;
}
if ($this->ipIsTrusted())
{
return;
}
$session = Factory::getSession();
$lastHit = $session->get('mokowaas.last_activity', 0);
$now = time();
if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60))
{
$this->getApplication()->logout();
$this->getApplication()->redirect(Route::_('index.php', false));
return;
}
$session->set('mokowaas.last_activity', $now);
}
/**
* Check whether the current request IP matches any trusted IP entry.
*/
private function ipIsTrusted(): bool
{
$entries = $this->params->get('trusted_ips', '');
if (empty($entries))
{
return false;
}
if (\is_string($entries))
{
$entries = json_decode($entries, true);
}
if (!\is_array($entries))
{
return false;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ipLong = ip2long($ip);
if ($ipLong === false)
{
return false;
}
foreach ($entries as $entry)
{
if (empty($entry['enabled']) || empty($entry['ip']))
{
continue;
}
$range = trim($entry['ip']);
// Wildcard: 192.168.1.*
if (str_contains($range, '*'))
{
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
if (preg_match($pattern, $ip))
{
return true;
}
continue;
}
// CIDR: 10.0.0.0/8
if (str_contains($range, '/'))
{
[$subnet, $bits] = explode('/', $range, 2);
$subnetLong = ip2long($subnet);
$mask = -1 << (32 - (int) $bits);
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
{
return true;
}
continue;
}
// Exact match
if ($ip === $range)
{
return true;
}
}
return false;
}
/**
* Override Joomla upload restrictions at runtime.
*/
private function enforceUploadRestrictions(): void
{
$types = $this->params->get('upload_allowed_types', '');
$maxMb = (int) $this->params->get('upload_max_size_mb', 0);
if (empty($types) && $maxMb <= 0)
{
return;
}
$config = $this->getApplication()->getConfig();
if (!empty($types))
{
$config->set('upload_extensions', $types);
}
if ($maxMb > 0)
{
$config->set('upload_maxsize', $maxMb);
}
}
}
@@ -0,0 +1,11 @@
; 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."
@@ -0,0 +1,3 @@
; MokoWaaS Health Monitor Plugin - System strings
PLG_SYSTEM_MOKOWAAS_MONITOR="System - MokoWaaS Monitor"
PLG_SYSTEM_MOKOWAAS_MONITOR_DESC="Site health monitoring, Grafana heartbeat integration, and diagnostics."
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS Monitor</name>
<element>mokowaas_monitor</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.00</version>
<description>PLG_SYSTEM_MOKOWAAS_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSMonitor</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas_monitor.ini</language>
<language tag="en-GB">en-GB/plg_system_mokowaas_monitor.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOWAAS_MONITOR_FIELDSET_BASIC_DESC">
<field name="heartbeat_enabled" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_MONITOR_HEARTBEAT_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_monitor
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoWaaSMonitor\Extension\Monitor;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Monitor($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_monitor'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,135 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_monitor
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSMonitor\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
/**
* MokoWaaS Health Monitor Plugin
*
* Provides Grafana heartbeat integration and site health diagnostics.
* The detailed 14-check health endpoint remains in the core plugin's API
* for now; this plugin handles the proactive monitoring side.
*
* @since 02.32.00
*/
class Monitor extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat';
private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m';
public static function getSubscribedEvents(): array
{
return [
'onExtensionAfterSave' => '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'
);
}
}
}
@@ -0,0 +1,23 @@
; MokoWaaS Tenant Restrictions Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOWAAS_TENANT="System - MokoWaaS Tenant"
PLG_SYSTEM_MOKOWAAS_TENANT_DESC="Restrict non-master user access to installer, sysinfo, global config, template editing, and admin menu items."
PLG_SYSTEM_MOKOWAAS_TENANT_FIELDSET_BASIC="Tenant Restrictions"
PLG_SYSTEM_MOKOWAAS_TENANT_FIELDSET_BASIC_DESC="Control which admin areas are accessible to non-master users."
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_INSTALLER_LABEL="Restrict Installer"
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_INSTALLER_DESC="Block access to the extension installer for non-master users."
PLG_SYSTEM_MOKOWAAS_TENANT_ALLOW_UPDATES_LABEL="Allow Extension Updates"
PLG_SYSTEM_MOKOWAAS_TENANT_ALLOW_UPDATES_DESC="Allow update views even when the installer is restricted."
PLG_SYSTEM_MOKOWAAS_TENANT_HIDE_SYSINFO_LABEL="Hide System Information"
PLG_SYSTEM_MOKOWAAS_TENANT_HIDE_SYSINFO_DESC="Block access to the System Information page."
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_CONFIG_LABEL="Restrict Global Configuration"
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_CONFIG_DESC="Block access to Global Configuration for non-master users."
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_TEMPLATE_LABEL="Restrict Template Editing"
PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_TEMPLATE_DESC="Block access to template source code editing."
PLG_SYSTEM_MOKOWAAS_TENANT_DISABLE_INSTALL_URL_LABEL="Disable Install from URL"
PLG_SYSTEM_MOKOWAAS_TENANT_DISABLE_INSTALL_URL_DESC="Prevent extension installation via remote URL."
PLG_SYSTEM_MOKOWAAS_TENANT_HIDDEN_MENUS_LABEL="Hidden Menu Items"
PLG_SYSTEM_MOKOWAAS_TENANT_HIDDEN_MENUS_DESC="Component option names to hide from admin menu (one per line, e.g. com_banners)."
@@ -0,0 +1,3 @@
; MokoWaaS Tenant Restrictions Plugin - System strings
PLG_SYSTEM_MOKOWAAS_TENANT="System - MokoWaaS Tenant"
PLG_SYSTEM_MOKOWAAS_TENANT_DESC="Restrict non-master user access to installer, sysinfo, global config, template editing, and admin menu items."
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS Tenant</name>
<element>mokowaas_tenant</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.00</version>
<description>PLG_SYSTEM_MOKOWAAS_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSTenant</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas_tenant.ini</language>
<language tag="en-GB">en-GB/plg_system_mokowaas_tenant.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOWAAS_TENANT_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOWAAS_TENANT_FIELDSET_BASIC_DESC">
<field name="restrict_installer" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_INSTALLER_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_INSTALLER_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="allow_extension_updates" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_ALLOW_UPDATES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_ALLOW_UPDATES_DESC"
class="btn-group btn-group-yesno"
showon="restrict_installer:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="hide_sysinfo" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_HIDE_SYSINFO_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_HIDE_SYSINFO_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="restrict_global_config" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_CONFIG_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_CONFIG_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="restrict_template_editing" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_TEMPLATE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_RESTRICT_TEMPLATE_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="disable_install_url" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_TENANT_DISABLE_INSTALL_URL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_DISABLE_INSTALL_URL_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="hidden_menu_items" type="textarea"
label="PLG_SYSTEM_MOKOWAAS_TENANT_HIDDEN_MENUS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TENANT_HIDDEN_MENUS_DESC"
rows="5" filter="raw" />
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_tenant
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoWaaSTenant\Extension\Tenant;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Tenant($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_tenant'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,207 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_tenant
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSTenant\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
/**
* MokoWaaS Tenant Restrictions Plugin
*
* Restricts non-master user access to installer, sysinfo, global config,
* template editing, and specified admin menu items.
*
* @since 02.32.00
*/
class Tenant extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterRoute' => 'onAfterRoute',
'onPreprocessMenuItems' => 'onPreprocessMenuItems',
];
}
/**
* Enforce admin area restrictions after routing.
*/
public function onAfterRoute(): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator'))
{
return;
}
if (MokoWaaSHelper::isMasterUser())
{
return;
}
$input = $app->getInput();
$option = $input->get('option', '');
$view = $input->get('view', '');
$task = $input->get('task', '');
// Disable install-from-URL
if ($this->params->get('disable_install_url', 1)
&& $option === 'com_installer'
&& stripos($task, 'install') !== false
&& $input->get('installtype') === 'url')
{
$this->blockAccess('Install from URL is disabled.');
return;
}
// Restrict installer (allow updates if configured)
if ($this->params->get('restrict_installer', 1) && $option === 'com_installer')
{
$allowUpdates = (int) $this->params->get('allow_extension_updates', 1);
if ($allowUpdates && \in_array($view, ['update', 'updatesites'], true))
{
// Update views are permitted
}
else
{
$this->blockAccess('Access restricted.');
return;
}
}
// Build blocked view rules
$blocked = [];
if ($this->params->get('hide_sysinfo', 1))
{
$blocked[] = ['option' => 'com_admin', 'view' => 'sysinfo'];
}
if ($this->params->get('restrict_global_config', 1))
{
$blocked[] = ['option' => 'com_config', 'view' => 'application'];
if ($option === 'com_config' && $view === '')
{
$this->blockAccess('Access restricted.');
return;
}
}
if ($this->params->get('restrict_template_editing', 1))
{
$blocked[] = ['option' => 'com_templates', 'view' => 'template'];
}
foreach ($blocked as $rule)
{
if ($option === $rule['option'] && $view === ($rule['view'] ?? ''))
{
$this->blockAccess('Access restricted.');
return;
}
}
}
/**
* Hide menu items for restricted components.
*/
public function onPreprocessMenuItems($event): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator'))
{
return;
}
if (MokoWaaSHelper::isMasterUser())
{
return;
}
$hidden = $this->getHiddenMenuComponents();
if (empty($hidden))
{
return;
}
// Get items by reference from the event
$items = &$event->getArgument(1);
if (!\is_array($items))
{
return;
}
foreach ($items as $key => $item)
{
foreach ($hidden as $component)
{
if (isset($item->link) && strpos($item->link, 'option=' . $component) !== false)
{
unset($items[$key]);
break;
}
}
}
}
/**
* Build the list of components to hide from admin menu.
*/
private function getHiddenMenuComponents(): array
{
$hidden = array_filter(array_map(
'trim',
explode("\n", $this->params->get('hidden_menu_items', ''))
));
// Implicitly hide components blocked by other settings
if ($this->params->get('restrict_installer', 1))
{
$hidden[] = 'com_installer';
}
if ($this->params->get('hide_sysinfo', 1))
{
$hidden[] = 'com_admin';
}
if ($this->params->get('restrict_global_config', 1))
{
$hidden[] = 'com_config';
}
return array_unique($hidden);
}
/**
* Redirect to admin dashboard with an error message.
*/
private function blockAccess(string $message): void
{
$app = $this->getApplication();
$app->enqueueMessage($message, 'error');
$app->redirect(Route::_('index.php', false));
}
}
+8 -5
View File
@@ -2,19 +2,22 @@
<extension type="package" method="upgrade">
<name>Package - MokoWaaS</name>
<packagename>mokowaas</packagename>
<version>02.31.00</version>
<version>02.31.00</version>
<creationDate>2026-05-23</creationDate>
<version>02.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoWaaS site management suite — branding, health monitoring, tenant restrictions, and REST API.</description>
<description>MokoWaaS site management suite — admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API.</description>
<scriptfile>script.php</scriptfile>
<files folder="packages">
<file type="plugin" id="plg_system_mokowaas" group="system">plg_system_mokowaas.zip</file>
<file type="plugin" id="plg_system_mokowaas_firewall" group="system">plg_system_mokowaas_firewall.zip</file>
<file type="plugin" id="plg_system_mokowaas_tenant" group="system">plg_system_mokowaas_tenant.zip</file>
<file type="plugin" id="plg_system_mokowaas_devtools" group="system">plg_system_mokowaas_devtools.zip</file>
<file type="plugin" id="plg_system_mokowaas_monitor" group="system">plg_system_mokowaas_monitor.zip</file>
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
@@ -23,6 +26,6 @@
</files>
<updateservers>
<server type="extension" priority="1" name="MokoWaaS Update Server">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml</server>
<server type="extension" priority="1" name="MokoWaaS Update Server">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml</server>
</updateservers>
</extension>
+134 -6
View File
@@ -38,10 +38,17 @@ class Pkg_MokowaasInstallerScript
$this->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();
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
@@ -198,6 +205,10 @@ class Pkg_MokowaasInstallerScript
$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('mokowaasdemo'),
$db->quote('mokowaassync'),
@@ -237,7 +248,7 @@ class Pkg_MokowaasInstallerScript
try
{
$db = Factory::getDbo();
$dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml';
$dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml';
// Find all MokoWaaS update sites
$query = $db->getQuery(true)
@@ -325,14 +336,16 @@ class Pkg_MokowaasInstallerScript
{
$db = Factory::getDbo();
// Migrate legacy static URL to dynamic MokoGitea endpoint
$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('https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'))
->where($db->quoteName('location') . ' LIKE '
. $db->quote('%MokoWaaS/raw/branch/%updates.xml%'))
->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();
@@ -414,4 +427,119 @@ class Pkg_MokowaasInstallerScript
// 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 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');
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<updates>
<update>
<name>Package - MokoWaaS</name>
<description>MokoWaaS site management suite</description>
<element>pkg_mokowaas</element>
<type>package</type>
<version>02.32.00</version>
<infourl title="MokoWaaS">https://mokoconsulting.tech</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/v02.32.00/pkg_mokowaas-02.32.00.zip</downloadurl>
</downloads>
<tags>
<tag>stable</tag>
</tags>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name="joomla" version="5\.[0-9]" />
<php_minimum>8.1</php_minimum>
</update>
</updates>