diff --git a/.gitignore b/.gitignore index 759676ee..ee502562 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 9732d0fa..6ba58f1c 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -9,7 +9,7 @@ Package - MokoWaaS MokoConsulting White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments - 02.31.00 + 02.32.00 GNU General Public License v3 diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml new file mode 100644 index 00000000..33aff715 --- /dev/null +++ b/.mokogitea/workflows/auto-bump.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/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" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml new file mode 100644 index 00000000..6fb2b441 --- /dev/null +++ b/.mokogitea/workflows/auto-release.yml @@ -0,0 +1,270 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/auto-release.yml.template +# VERSION: 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 diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml new file mode 100644 index 00000000..67a735f8 --- /dev/null +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml new file mode 100644 index 00000000..5f7c1d72 --- /dev/null +++ b/.mokogitea/workflows/cascade-dev.yml @@ -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" diff --git a/.mokogitea/workflows/ci-platform.yml b/.mokogitea/workflows/ci-platform.yml new file mode 100644 index 00000000..b17e62bf --- /dev/null +++ b/.mokogitea/workflows/ci-platform.yml @@ -0,0 +1,439 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml new file mode 100644 index 00000000..70521b36 --- /dev/null +++ b/.mokogitea/workflows/cleanup.yml @@ -0,0 +1,87 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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)" diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml new file mode 100644 index 00000000..9126c916 --- /dev/null +++ b/.mokogitea/workflows/gitleaks.yml @@ -0,0 +1,96 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml new file mode 100644 index 00000000..3c44a06e --- /dev/null +++ b/.mokogitea/workflows/issue-branch.yml @@ -0,0 +1,73 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml new file mode 100644 index 00000000..c18b8097 --- /dev/null +++ b/.mokogitea/workflows/notify.yml @@ -0,0 +1,70 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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}" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml new file mode 100644 index 00000000..d1aac4e6 --- /dev/null +++ b/.mokogitea/workflows/pr-check.yml @@ -0,0 +1,236 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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 '/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 diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 00000000..ff818ba0 --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,224 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/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 diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml new file mode 100644 index 00000000..b619d894 --- /dev/null +++ b/.mokogitea/workflows/repo-health.yml @@ -0,0 +1,769 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# 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 '/dev/null | head -1 || true)" + if [ -z "${MANIFEST}" ]; then + joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") + else + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: 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 '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP ' 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 + diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml new file mode 100644 index 00000000..1bd94702 --- /dev/null +++ b/.mokogitea/workflows/security-audit.yml @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 }} + diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml new file mode 100644 index 00000000..ac5c9a52 --- /dev/null +++ b/.mokogitea/workflows/update-server.yml @@ -0,0 +1,302 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /templates/workflows/update-server.yml +# VERSION: 09.23.00 +# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches +# +# Thin wrapper around moko-platform CLI tools. +# Builds packages, updates updates.xml, and optionally deploys via SFTP. +# +# Joomla filters update entries by the user's "Minimum Stability" setting. + +name: "Update Server" + +on: + push: + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + pull_request: + types: [closed] + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + inputs: + stability: + description: 'Stability tag' + required: true + default: 'development' + type: choice + options: + - development + - alpha + - beta + - rc + - stable + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + update-xml: + name: Update Server + runs-on: release + if: >- + github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 0 + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}' + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + # Always fetch latest CLI tools — never use stale cache from previous runs + rm -rf /tmp/moko-platform + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform 2>/dev/null || true + if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then + cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true + fi + echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV" + + - name: Detect platform + id: platform + run: php ${MOKO_CLI}/manifest_read.php --path . --github-output + + - name: Resolve stability and bump version + id: meta + run: | + BRANCH="${{ github.ref_name }}" + + # Configure git for bot pushes + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + # Determine stability from branch or manual input + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + STABILITY="${{ inputs.stability }}" + elif [[ "$BRANCH" == rc/* ]]; then + STABILITY="rc" + elif [[ "$BRANCH" == beta/* ]]; then + STABILITY="beta" + elif [[ "$BRANCH" == alpha/* ]]; then + STABILITY="alpha" + else + STABILITY="development" + fi + + # Gitea release tag per stability + case "$STABILITY" in + development) TAG="development" ;; + alpha) TAG="alpha" ;; + beta) TAG="beta" ;; + rc) TAG="release-candidate" ;; + *) TAG="stable" ;; + esac + + # Bump patch, set platform suffix, fix consistency — version_bump preserves suffix + php ${MOKO_CLI}/version_set_platform.php \ + --path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \ + --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true + php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true + php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true + + # Read final version (includes suffix, e.g. 01.02.15-dev) + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + # Commit version bump if changed + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \ + --author="gitea-actions[bot] " + git push + } + + - name: Create release and upload package + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Create or update Gitea release + php ${MOKO_CLI}/release_create.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease + + # Build package and upload + php ${MOKO_CLI}/release_package.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --output /tmp || true + + - name: Update updates.xml + if: steps.platform.outputs.platform == 'joomla' + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + + if [ ! -f "updates.xml" ]; then + echo "No updates.xml — skipping" + exit 0 + fi + + SHA_FLAG="" + [ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}" + + php ${MOKO_CLI}/updates_xml_build.php \ + --path . --version "${VERSION}" --stability "${STABILITY}" \ + --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ + ${SHA_FLAG} + + # Commit and push updates.xml + git add updates.xml + git diff --cached --quiet || { + git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]" + git push + } + + - name: Sync updates.xml to main + if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla' + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \ + "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) + + if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then + python3 -c " + import base64, json, urllib.request, sys + with open('updates.xml', 'rb') as f: + content = base64.b64encode(f.read()).decode() + payload = json.dumps({ + 'content': content, + 'sha': '${FILE_SHA}', + 'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]', + 'branch': 'main' + }).encode() + req = urllib.request.Request( + '${API_BASE}/contents/updates.xml', + data=payload, method='PUT', + headers={ + 'Authorization': 'token ${GITEA_TOKEN}', + 'Content-Type': 'application/json' + }) + try: + urllib.request.urlopen(req) + print('updates.xml synced to main') + except Exception as e: + print(f'WARNING: sync to main failed: {e}', file=sys.stderr) + " + fi + + - name: SFTP deploy to dev server + if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev' + env: + DEV_HOST: ${{ vars.DEV_FTP_HOST }} + DEV_PATH: ${{ vars.DEV_FTP_PATH }} + DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }} + DEV_USER: ${{ vars.DEV_FTP_USERNAME }} + DEV_PORT: ${{ vars.DEV_FTP_PORT }} + DEV_KEY: ${{ secrets.DEV_FTP_KEY }} + DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }} + run: | + # Permission check: admin or maintain role required + ACTOR="${{ github.actor }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read") + case "$PERMISSION" in + admin|maintain|write) ;; + *) + echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write" + exit 0 + ;; + esac + + [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; } + + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && exit 0 + + PORT="${DEV_PORT:-22}" + REMOTE="${DEV_PATH%/}" + [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}" + + printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ + "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json + if [ -n "$DEV_KEY" ]; then + echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key + printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json + else + printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json + fi + + PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true) + if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then + php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then + php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + fi + rm -f /tmp/deploy_key /tmp/sftp-config.json + echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY + + - name: Summary + if: always() + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + DISPLAY="${VERSION}" + echo "## Update Server" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03e794..4f1d0201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini new file mode 100644 index 00000000..d9264ac6 --- /dev/null +++ b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini @@ -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." diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini new file mode 100644 index 00000000..ac058b55 --- /dev/null +++ b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini @@ -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" diff --git a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php new file mode 100644 index 00000000..f00ee4e2 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php @@ -0,0 +1,91 @@ +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(); + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php b/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php new file mode 100644 index 00000000..5fc3091c --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php @@ -0,0 +1,305 @@ + [ + '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; + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php new file mode 100644 index 00000000..8c535d09 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php @@ -0,0 +1,58 @@ +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'); + } + } +} diff --git a/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php b/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php new file mode 100644 index 00000000..dc0f6d8a --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php @@ -0,0 +1,154 @@ +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']; +?> + +
+ +
+
+
+ + escape($siteInfo->sitename); ?> +
+
+ MokoWaaS + escape($siteInfo->mokowaas_version); ?> +
+
+ Joomla + escape($siteInfo->joomla_version); ?> +
+
+ PHP + escape($siteInfo->php_version); ?> +
+
+ + escape($siteInfo->db_type); ?> +
+ debug): ?> +
+ +
+ + offline): ?> +
+ +
+ +
+
+ + +
+
+ + + + + +
+
+ + + + + +

+ escape($first->categoryLabel); ?> +

+
+ +
+
+
+
+
+ +
escape($plugin->name); ?>
+
+ version): ?> + escape($plugin->version); ?> + +
+

escape($plugin->description); ?>

+
+
+ protected): ?> + + +
+ enabled ? 'checked' : ''; ?> + > + +
+ +
+ type === 'plugin') + { + $configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); + } + ?> + + + + + + +
+
+
+
+ +
+ +
diff --git a/src/packages/com_mokowaas/media/css/dashboard.css b/src/packages/com_mokowaas/media/css/dashboard.css new file mode 100644 index 00000000..91bf844b --- /dev/null +++ b/src/packages/com_mokowaas/media/css/dashboard.css @@ -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; +} diff --git a/src/packages/com_mokowaas/media/js/dashboard.js b/src/packages/com_mokowaas/media/js/dashboard.js new file mode 100644 index 00000000..df8433ed --- /dev/null +++ b/src/packages/com_mokowaas/media/js/dashboard.js @@ -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'; + }); + }); + }); + } +}); diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml index 5a403688..ab0bbc20 100644 --- a/src/packages/com_mokowaas/mokowaas.xml +++ b/src/packages/com_mokowaas/mokowaas.xml @@ -1,24 +1,49 @@ + - MokoWaaS API + MokoWaaS Moko Consulting - 2026-05-23 + 2026-06-02 Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.31.00 - 02.31.00 - Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups. + 02.32.00 + MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. + + Moko\Component\MokoWaaS\Administrator Moko\Component\MokoWaaS\Api + + MokoWaaS + language services + src + tmpl + src + + + css + js + diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php index 1b54b90d..09b827f3 100644 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -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( - 'MokoWaaS License Key Required — ' + 'Moko Consulting License Key Required — ' . 'No download key is configured. Updates will not be available until a valid license key is entered. ' . 'Go to System → Update Sites ' - . 'and enter your license key (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( - 'MokoWaaS License Key Invalid — ' + 'Moko Consulting License Key Invalid — ' . 'Your license key could not be validated. Please verify your key in ' . 'System → Update Sites.', 'error' @@ -4430,7 +4440,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface if (!$isValid) { $this->app->enqueueMessage( - 'MokoWaaS License Key Invalid — ' + 'Moko Consulting License Key Invalid — ' . 'Your license key could not be validated. Updates will not be available. ' . 'Please verify your key in ' . 'System → Update Sites.', diff --git a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php new file mode 100644 index 00000000..0cd42177 --- /dev/null +++ b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php @@ -0,0 +1,96 @@ +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 ?? '{}'); + } +} diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml index 920104c3..d8f3c402 100644 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ b/src/packages/plg_system_mokowaas/mokowaas.xml @@ -30,8 +30,7 @@ GNU General Public License version 3 or later; see LICENSE.md hello@mokoconsulting.tech https://mokoconsulting.tech - 02.31.00 - 02.31.00 + 02.32.00 This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. Moko\Plugin\System\MokoWaaS script.php @@ -40,6 +39,7 @@ script.php Extension Field + Helper Service forms payload diff --git a/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini b/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini new file mode 100644 index 00000000..8fb821be --- /dev/null +++ b/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini b/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini new file mode 100644 index 00000000..66dbd07c --- /dev/null +++ b/src/packages/plg_system_mokowaas_devtools/language/en-GB/plg_system_mokowaas_devtools.sys.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml new file mode 100644 index 00000000..23be3354 --- /dev/null +++ b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml @@ -0,0 +1,58 @@ + + + System - MokoWaaS DevTools + mokowaas_devtools + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.00 + PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC + Moko\Plugin\System\MokoWaaSDevTools + + + src + services + language + + + + en-GB/plg_system_mokowaas_devtools.ini + en-GB/plg_system_mokowaas_devtools.sys.ini + + + + +
+ + + + + + + + + + + + + + + +
+
+
+
diff --git a/src/packages/plg_system_mokowaas_devtools/services/provider.php b/src/packages/plg_system_mokowaas_devtools/services/provider.php new file mode 100644 index 00000000..42bce794 --- /dev/null +++ b/src/packages/plg_system_mokowaas_devtools/services/provider.php @@ -0,0 +1,34 @@ +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; + } + ); + } +}; diff --git a/src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php b/src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php new file mode 100644 index 00000000..cc6f5a90 --- /dev/null +++ b/src/packages/plg_system_mokowaas_devtools/src/Extension/DevTools.php @@ -0,0 +1,155 @@ + '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; + } +} diff --git a/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini b/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini new file mode 100644 index 00000000..ea604380 --- /dev/null +++ b/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.ini @@ -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 & 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." diff --git a/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini b/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini new file mode 100644 index 00000000..819519ad --- /dev/null +++ b/src/packages/plg_system_mokowaas_firewall/language/en-GB/plg_system_mokowaas_firewall.sys.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml new file mode 100644 index 00000000..d1300ce7 --- /dev/null +++ b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml @@ -0,0 +1,101 @@ + + + System - MokoWaaS Firewall + mokowaas_firewall + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.00 + PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC + Moko\Plugin\System\MokoWaaSFirewall + + + src + services + language + + + + en-GB/plg_system_mokowaas_firewall.ini + en-GB/plg_system_mokowaas_firewall.sys.ini + + + + +
+ + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+
+
+
diff --git a/src/packages/plg_system_mokowaas_firewall/services/provider.php b/src/packages/plg_system_mokowaas_firewall/services/provider.php new file mode 100644 index 00000000..20196326 --- /dev/null +++ b/src/packages/plg_system_mokowaas_firewall/services/provider.php @@ -0,0 +1,34 @@ +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; + } + ); + } +}; diff --git a/src/packages/plg_system_mokowaas_firewall/src/Extension/Firewall.php b/src/packages/plg_system_mokowaas_firewall/src/Extension/Firewall.php new file mode 100644 index 00000000..60ea056b --- /dev/null +++ b/src/packages/plg_system_mokowaas_firewall/src/Extension/Firewall.php @@ -0,0 +1,259 @@ + '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); + } + } +} diff --git a/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini b/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini new file mode 100644 index 00000000..62115221 --- /dev/null +++ b/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini b/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini new file mode 100644 index 00000000..fca62b05 --- /dev/null +++ b/src/packages/plg_system_mokowaas_monitor/language/en-GB/plg_system_mokowaas_monitor.sys.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml new file mode 100644 index 00000000..8ff30ba7 --- /dev/null +++ b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml @@ -0,0 +1,42 @@ + + + System - MokoWaaS Monitor + mokowaas_monitor + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.00 + PLG_SYSTEM_MOKOWAAS_MONITOR_DESC + Moko\Plugin\System\MokoWaaSMonitor + + + src + services + language + + + + en-GB/plg_system_mokowaas_monitor.ini + en-GB/plg_system_mokowaas_monitor.sys.ini + + + + +
+ + + + + +
+
+
+
diff --git a/src/packages/plg_system_mokowaas_monitor/services/provider.php b/src/packages/plg_system_mokowaas_monitor/services/provider.php new file mode 100644 index 00000000..ed952d39 --- /dev/null +++ b/src/packages/plg_system_mokowaas_monitor/services/provider.php @@ -0,0 +1,34 @@ +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; + } + ); + } +}; diff --git a/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php b/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php new file mode 100644 index 00000000..34a20c08 --- /dev/null +++ b/src/packages/plg_system_mokowaas_monitor/src/Extension/Monitor.php @@ -0,0 +1,135 @@ + 'onExtensionAfterSave', + ]; + } + + /** + * After saving this plugin or the core plugin, send heartbeat. + */ + public function onExtensionAfterSave($event): void + { + $context = $event->getArgument(0, ''); + $table = $event->getArgument(1); + + if ($context !== 'com_plugins.plugin' || !$table) + { + return; + } + + $element = $table->element ?? ''; + + // Trigger heartbeat when core or monitor plugin is saved + if (!\in_array($element, ['mokowaas', 'mokowaas_monitor'], true)) + { + return; + } + + if (!$this->params->get('heartbeat_enabled', 1)) + { + return; + } + + $this->sendHeartbeat(); + } + + /** + * Send heartbeat registration to the Grafana monitoring receiver. + */ + private function sendHeartbeat(): void + { + $coreParams = MokoWaaSHelper::getCoreParams(); + $healthToken = $coreParams->get('health_api_token', ''); + + if (empty($healthToken)) + { + return; + } + + $app = $this->getApplication(); + $siteUrl = rtrim(Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $healthToken, + 'action' => 'register', + ], JSON_UNESCAPED_SLASHES); + + $ch = curl_init(self::HEARTBEAT_URL . '/register'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) + { + Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); + } + elseif ($code === 200) + { + $body = json_decode($response, true); + $app->enqueueMessage( + 'Grafana heartbeat: ' . ($body['status'] ?? 'ok'), + 'message' + ); + } + else + { + $body = json_decode($response, true); + Log::add( + \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), + Log::WARNING, + 'mokowaas' + ); + } + } +} diff --git a/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini b/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini new file mode 100644 index 00000000..a74f4c82 --- /dev/null +++ b/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.ini @@ -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)." diff --git a/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini b/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini new file mode 100644 index 00000000..935d9ec9 --- /dev/null +++ b/src/packages/plg_system_mokowaas_tenant/language/en-GB/plg_system_mokowaas_tenant.sys.ini @@ -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." diff --git a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml new file mode 100644 index 00000000..619a6367 --- /dev/null +++ b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml @@ -0,0 +1,88 @@ + + + System - MokoWaaS Tenant + mokowaas_tenant + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.00 + PLG_SYSTEM_MOKOWAAS_TENANT_DESC + Moko\Plugin\System\MokoWaaSTenant + + + src + services + language + + + + en-GB/plg_system_mokowaas_tenant.ini + en-GB/plg_system_mokowaas_tenant.sys.ini + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/src/packages/plg_system_mokowaas_tenant/services/provider.php b/src/packages/plg_system_mokowaas_tenant/services/provider.php new file mode 100644 index 00000000..fa8b3efd --- /dev/null +++ b/src/packages/plg_system_mokowaas_tenant/services/provider.php @@ -0,0 +1,34 @@ +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; + } + ); + } +}; diff --git a/src/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php b/src/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php new file mode 100644 index 00000000..9dbfe30c --- /dev/null +++ b/src/packages/plg_system_mokowaas_tenant/src/Extension/Tenant.php @@ -0,0 +1,207 @@ + '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)); + } +} diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml index 14354495..855b323d 100644 --- a/src/pkg_mokowaas.xml +++ b/src/pkg_mokowaas.xml @@ -2,19 +2,22 @@ Package - MokoWaaS mokowaas - 02.31.00 - 02.31.00 - 2026-05-23 + 02.32.00 + 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE - MokoWaaS site management suite — branding, health monitoring, tenant restrictions, and REST API. + MokoWaaS site management suite — admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API. script.php plg_system_mokowaas.zip + plg_system_mokowaas_firewall.zip + plg_system_mokowaas_tenant.zip + plg_system_mokowaas_devtools.zip + plg_system_mokowaas_monitor.zip com_mokowaas.zip plg_webservices_mokowaas.zip plg_webservices_perfectpublisher.zip @@ -23,6 +26,6 @@ - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml diff --git a/src/script.php b/src/script.php index 0d92f652..6653fea3 100644 --- a/src/script.php +++ b/src/script.php @@ -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'); + } + } } diff --git a/updates.xml b/updates.xml new file mode 100644 index 00000000..bfd06f09 --- /dev/null +++ b/updates.xml @@ -0,0 +1,21 @@ + + + + Package - MokoWaaS + MokoWaaS site management suite + pkg_mokowaas + package + 02.32.00 + https://mokoconsulting.tech + + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/v02.32.00/pkg_mokowaas-02.32.00.zip + + + stable + + Moko Consulting + https://mokoconsulting.tech + + 8.1 + +