diff --git a/.gitattributes b/.gitattributes index 998448a..205c550 100644 --- a/.gitattributes +++ b/.gitattributes @@ -60,3 +60,4 @@ CODE_OF_CONDUCT.md export-ignore Makefile export-ignore composer.json export-ignore phpstan.neon export-ignore +*.yml text eol=lf diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml index cb078c6..6c13103 100644 --- a/.mokogitea/workflows/auto-bump.yml +++ b/.mokogitea/workflows/auto-bump.yml @@ -1,66 +1,66 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: mokocli.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli -# PATH: /.mokogitea/workflows/auto-bump.yml -# VERSION: 09.02.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 mokocli 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/mokocli/cli" ]; then - echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" - else - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ - /tmp/mokocli - cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet - echo "MOKO_CLI=/tmp/mokocli/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" +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/auto-bump.yml +# VERSION: 09.02.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 mokocli 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/mokocli/cli" ]; then + echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ + /tmp/mokocli + cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/mokocli/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/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 75a6963..9291e38 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# INGROUP: moko-platform.Automation +# VERSION: 01.25.02 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index d34108c..b1037e7 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -1,534 +1,534 @@ -# 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 - - # ── Secret Scanning ────────────────────────────────────────────────── - gitleaks: - name: 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 - - - name: Scan PR commits for secrets - run: | - if gitleaks detect --source . --verbose \ - --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then - echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY - else - echo "::error::Potential secrets detected in PR commits" - exit 1 - fi - - # ── Code Validation ──────────────────────────────────────────────────── - validate: - name: Validate PR - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check for merge conflict markers - run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) - if [ -n "$CONFLICTS" ]; then - echo "::error::Merge conflict markers found in source files" - echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "No conflict markers found" - - - name: Detect platform - id: platform - run: | - # 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: Joomla JEXEC guard check - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - while IFS= read -r -d '' file; do - # Skip vendor, node_modules, and index.html stub files - case "$file" in ./vendor/*|./node_modules/*) continue ;; esac - # Check first 10 lines for JEXEC or JPATH guard - if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then - echo "::error file=${file}::Missing JEXEC guard: ${file}" - ERRORS=$((ERRORS + 1)) - fi - done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) - if [ "$ERRORS" -gt 0 ]; then - echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" - echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY - echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "JEXEC guard: OK" - - - name: Joomla directory listing protection - if: steps.platform.outputs.platform == 'joomla' - run: | - MISSING=0 - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && exit 0 - while IFS= read -r dir; do - if [ ! -f "${dir}/index.html" ]; then - echo "::warning::Missing index.html in ${dir} (directory listing protection)" - MISSING=$((MISSING + 1)) - fi - done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") - if [ "$MISSING" -gt 0 ]; then - echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY - echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY - fi - echo "Directory protection: ${MISSING} missing (advisory)" - - - name: Joomla script file and asset checks - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - [ -z "$MANIFEST" ] && exit 0 - MANIFEST_DIR=$(dirname "$MANIFEST") - - # Check scriptfile exists if declared - SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) - if [ -n "$SCRIPTFILE" ]; then - if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then - echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" - ERRORS=$((ERRORS + 1)) - else - echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" - fi - fi - - # Require joomla.asset.json and validate it - ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$ASSET_JSON" ]; then - echo "::error::joomla.asset.json not found — Joomla asset system is required" - ERRORS=$((ERRORS + 1)) - else - if command -v php &> /dev/null; then - php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { - echo "::error::joomla.asset.json is not valid JSON" - ERRORS=$((ERRORS + 1)) - } - fi - echo "joomla.asset.json: valid" - fi - - # Validate all XML files in src/ are well-formed - XML_ERRORS=0 - if command -v php &> /dev/null; then - while IFS= read -r -d '' xmlfile; do - if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then - XML_ERRORS=$((XML_ERRORS + 1)) - fi - done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) - fi - if [ "$XML_ERRORS" -gt 0 ]; then - echo "::error::${XML_ERRORS} XML file(s) are malformed" - ERRORS=$((ERRORS + 1)) - else - echo "XML well-formedness: OK" - fi - - [ "$ERRORS" -gt 0 ] && exit 1 - echo "Joomla asset checks: OK" - - - name: Validate platform manifest - run: | - PLATFORM="${{ steps.platform.outputs.platform }}" - 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 - # Block legacy raw/branch update server URLs on MokoGitea - RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) - if [ -n "$RAW_URLS" ]; then - echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" - echo "$RAW_URLS" - exit 1 - fi - echo "Joomla manifest valid" - ;; - dolibarr) - 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: Validate Joomla language files - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - WARNINGS=0 - - # Require both en-GB and en-US language directories - LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$LANG_ROOT" ]; then - echo "No language/ directory found — skipping" - exit 0 - fi - - if [ ! -d "$LANG_ROOT/en-GB" ]; then - echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" - ERRORS=$((ERRORS + 1)) - fi - if [ ! -d "$LANG_ROOT/en-US" ]; then - echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" - ERRORS=$((ERRORS + 1)) - fi - - # Check that en-GB and en-US have matching .ini files - if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then - for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do - [ ! -f "$GB_INI" ] && continue - US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" - if [ ! -f "$US_INI" ]; then - echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" - ERRORS=$((ERRORS + 1)) - fi - done - for US_INI in "$LANG_ROOT/en-US"/*.ini; do - [ ! -f "$US_INI" ] && continue - GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" - if [ ! -f "$GB_INI" ]; then - echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" - ERRORS=$((ERRORS + 1)) - fi - done - fi - - # Find all .ini language files - INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) - if [ -z "$INI_FILES" ]; then - echo "No .ini language files found" - [ "$ERRORS" -gt 0 ] && exit 1 - exit 0 - fi - - echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" - - for FILE in $INI_FILES; do - FNAME=$(basename "$FILE") - LINENUM=0 - SEEN_KEYS="" - - while IFS= read -r line || [ -n "$line" ]; do - LINENUM=$((LINENUM + 1)) - - # Skip empty lines and comments - [ -z "$line" ] && continue - echo "$line" | grep -qE '^\s*;' && continue - echo "$line" | grep -qE '^\s*$' && continue - - # Must match KEY="VALUE" format - if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then - echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" - ERRORS=$((ERRORS + 1)) - continue - fi - - # Extract key and check for duplicates - KEY=$(echo "$line" | sed 's/=.*//') - if echo "$SEEN_KEYS" | grep -qx "$KEY"; then - echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" - ERRORS=$((ERRORS + 1)) - fi - SEEN_KEYS="${SEEN_KEYS} - ${KEY}" - done < "$FILE" - - echo " ${FILE}: checked ${LINENUM} lines" - done - - # Cross-check en-GB vs en-US key consistency - GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) - US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) - - if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then - for GB_FILE in "$GB_DIR"/*.ini; do - [ ! -f "$GB_FILE" ] && continue - FNAME=$(basename "$GB_FILE") - US_FILE="$US_DIR/$FNAME" - [ ! -f "$US_FILE" ] && continue - - GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) - US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) - - # Keys in en-GB but not en-US - MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_US" ]; then - echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" - echo "$MISSING_US" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - - # Keys in en-US but not en-GB - MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_GB" ]; then - echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" - echo "$MISSING_GB" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - done - fi - - { - echo "### Language File Validation" - echo "| Metric | Count |" - echo "|---|---|" - echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" - echo "| Errors | ${ERRORS} |" - echo "| Warnings | ${WARNINGS} |" - } >> $GITHUB_STEP_SUMMARY - - if [ "$ERRORS" -gt 0 ]; then - echo "::error::Language validation failed with ${ERRORS} error(s)" - exit 1 - fi - echo "Language files: OK (${WARNINGS} warning(s))" - - - name: Check changelog has unreleased entry - run: | - if [ ! -f "CHANGELOG.md" ]; then - 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 - - # ── Issue Reporter ────────────────────────────────────────────────────── - report-issues: - name: Report Issues - runs-on: ubuntu-latest - needs: [branch-policy, validate] - if: >- - always() && - needs.validate.result == 'failure' - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issue for PR validation failure" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - ./automation/ci-issue-reporter.sh \ - --gate "PR Validation" \ - --workflow "PR Check" \ - --severity error \ - --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." +# 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 + + # ── Secret Scanning ────────────────────────────────────────────────── + gitleaks: + name: 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 + + - name: Scan PR commits for secrets + run: | + if gitleaks detect --source . --verbose \ + --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then + echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY + else + echo "::error::Potential secrets detected in PR commits" + exit 1 + fi + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found in source files" + echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Detect platform + id: platform + run: | + # 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: Joomla JEXEC guard check + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + # Skip vendor, node_modules, and index.html stub files + case "$file" in ./vendor/*|./node_modules/*) continue ;; esac + # Check first 10 lines for JEXEC or JPATH guard + if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + echo "::error file=${file}::Missing JEXEC guard: ${file}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" + echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "JEXEC guard: OK" + + - name: Joomla directory listing protection + if: steps.platform.outputs.platform == 'joomla' + run: | + MISSING=0 + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && exit 0 + while IFS= read -r dir; do + if [ ! -f "${dir}/index.html" ]; then + echo "::warning::Missing index.html in ${dir} (directory listing protection)" + MISSING=$((MISSING + 1)) + fi + done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") + if [ "$MISSING" -gt 0 ]; then + echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY + echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY + fi + echo "Directory protection: ${MISSING} missing (advisory)" + + - name: Joomla script file and asset checks + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && exit 0 + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check scriptfile exists if declared + SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) + if [ -n "$SCRIPTFILE" ]; then + if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then + echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" + ERRORS=$((ERRORS + 1)) + else + echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" + fi + fi + + # Require joomla.asset.json and validate it + ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ASSET_JSON" ]; then + echo "::error::joomla.asset.json not found — Joomla asset system is required" + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { + echo "::error::joomla.asset.json is not valid JSON" + ERRORS=$((ERRORS + 1)) + } + fi + echo "joomla.asset.json: valid" + fi + + # Validate all XML files in src/ are well-formed + XML_ERRORS=0 + if command -v php &> /dev/null; then + while IFS= read -r -d '' xmlfile; do + if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then + XML_ERRORS=$((XML_ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) + fi + if [ "$XML_ERRORS" -gt 0 ]; then + echo "::error::${XML_ERRORS} XML file(s) are malformed" + ERRORS=$((ERRORS + 1)) + else + echo "XML well-formedness: OK" + fi + + [ "$ERRORS" -gt 0 ] && exit 1 + echo "Joomla asset checks: OK" + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + 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 + # Block legacy raw/branch update server URLs on MokoGitea + RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) + if [ -n "$RAW_URLS" ]; then + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "$RAW_URLS" + exit 1 + fi + echo "Joomla manifest valid" + ;; + dolibarr) + 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: Validate Joomla language files + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + WARNINGS=0 + + # Require both en-GB and en-US language directories + LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$LANG_ROOT" ]; then + echo "No language/ directory found — skipping" + exit 0 + fi + + if [ ! -d "$LANG_ROOT/en-GB" ]; then + echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" + ERRORS=$((ERRORS + 1)) + fi + if [ ! -d "$LANG_ROOT/en-US" ]; then + echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" + ERRORS=$((ERRORS + 1)) + fi + + # Check that en-GB and en-US have matching .ini files + if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then + for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do + [ ! -f "$GB_INI" ] && continue + US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" + if [ ! -f "$US_INI" ]; then + echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" + ERRORS=$((ERRORS + 1)) + fi + done + for US_INI in "$LANG_ROOT/en-US"/*.ini; do + [ ! -f "$US_INI" ] && continue + GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" + if [ ! -f "$GB_INI" ]; then + echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # Find all .ini language files + INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) + if [ -z "$INI_FILES" ]; then + echo "No .ini language files found" + [ "$ERRORS" -gt 0 ] && exit 1 + exit 0 + fi + + echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" + + for FILE in $INI_FILES; do + FNAME=$(basename "$FILE") + LINENUM=0 + SEEN_KEYS="" + + while IFS= read -r line || [ -n "$line" ]; do + LINENUM=$((LINENUM + 1)) + + # Skip empty lines and comments + [ -z "$line" ] && continue + echo "$line" | grep -qE '^\s*;' && continue + echo "$line" | grep -qE '^\s*$' && continue + + # Must match KEY="VALUE" format + if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then + echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" + ERRORS=$((ERRORS + 1)) + continue + fi + + # Extract key and check for duplicates + KEY=$(echo "$line" | sed 's/=.*//') + if echo "$SEEN_KEYS" | grep -qx "$KEY"; then + echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" + ERRORS=$((ERRORS + 1)) + fi + SEEN_KEYS="${SEEN_KEYS} + ${KEY}" + done < "$FILE" + + echo " ${FILE}: checked ${LINENUM} lines" + done + + # Cross-check en-GB vs en-US key consistency + GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) + US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) + + if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then + for GB_FILE in "$GB_DIR"/*.ini; do + [ ! -f "$GB_FILE" ] && continue + FNAME=$(basename "$GB_FILE") + US_FILE="$US_DIR/$FNAME" + [ ! -f "$US_FILE" ] && continue + + GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) + US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) + + # Keys in en-GB but not en-US + MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_US" ]; then + echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" + echo "$MISSING_US" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + + # Keys in en-US but not en-GB + MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_GB" ]; then + echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" + echo "$MISSING_GB" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + done + fi + + { + echo "### Language File Validation" + echo "| Metric | Count |" + echo "|---|---|" + echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" + echo "| Errors | ${ERRORS} |" + echo "| Warnings | ${WARNINGS} |" + } >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Language validation failed with ${ERRORS} error(s)" + exit 1 + fi + echo "Language files: OK (${WARNINGS} warning(s))" + + - name: Check changelog has unreleased entry + run: | + if [ ! -f "CHANGELOG.md" ]; then + 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 + + # ── Issue Reporter ────────────────────────────────────────────────────── + report-issues: + name: Report Issues + runs-on: ubuntu-latest + needs: [branch-policy, validate] + if: >- + always() && + needs.validate.result == 'failure' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml index 154f77d..6a25f5b 100644 --- a/.mokogitea/workflows/repo-health.yml +++ b/.mokogitea/workflows/repo-health.yml @@ -1,712 +1,712 @@ -# ============================================================================ -# 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: mokocli.Validation -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli -# PATH: /templates/workflows/joomla/repo_health.yml.template -# VERSION: 09.23.00 -# BRIEF: Enforces repository guardrails by validating 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, scripts, or repo' - required: true - default: all - type: choice - options: - - all - - scripts - - repo - pull_request: - branches: - - main - -permissions: - contents: read - -env: - # 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 - - 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|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${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|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${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 policy | N/A | Releases handled by MokoGitea |' - printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' - printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' - printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' - 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 - - # ═══════════════════════════════════════════════════════════════════════ - # Issue Reporter — file issues for failed gates - # ═══════════════════════════════════════════════════════════════════════ - report-issues: - name: "Report Issues" - runs-on: ubuntu-latest - needs: [access_check, scripts_governance, repo_health] - if: >- - always() && - (needs.scripts_governance.result == 'failure' || - needs.repo_health.result == 'failure') - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issues for failed gates" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - REPORTER="./automation/ci-issue-reporter.sh" - WF="Repo Health" - - report_gate() { - local gate="$1" result="$2" details="$3" - if [ "$result" = "failure" ]; then - "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error - fi - } - - report_gate "Scripts Governance" \ - "${{ needs.scripts_governance.result }}" \ - "Scripts directory policy violations detected. Review required and allowed directories." - - report_gate "Repository Health" \ - "${{ needs.repo_health.result }}" \ - "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." +# ============================================================================ +# 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: mokocli.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# PATH: /templates/workflows/joomla/repo_health.yml.template +# VERSION: 09.23.00 +# BRIEF: Enforces repository guardrails by validating 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, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - scripts + - repo + pull_request: + branches: + - main + +permissions: + contents: read + +env: + # 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 + + 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|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${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|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${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 policy | N/A | Releases handled by MokoGitea |' + printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' + printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' + printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' + 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 + + # ═══════════════════════════════════════════════════════════════════════ + # Issue Reporter — file issues for failed gates + # ═══════════════════════════════════════════════════════════════════════ + report-issues: + name: "Report Issues" + runs-on: ubuntu-latest + needs: [access_check, scripts_governance, repo_health] + if: >- + always() && + (needs.scripts_governance.result == 'failure' || + needs.repo_health.result == 'failure') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issues for failed gates" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + REPORTER="./automation/ci-issue-reporter.sh" + WF="Repo Health" + + report_gate() { + local gate="$1" result="$2" details="$3" + if [ "$result" = "failure" ]; then + "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error + fi + } + + report_gate "Scripts Governance" \ + "${{ needs.scripts_governance.result }}" \ + "Scripts directory policy violations detected. Review required and allowed directories." + + report_gate "Repository Health" \ + "${{ needs.repo_health.result }}" \ + "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." diff --git a/README.md b/README.md index 344e705..6701107 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteBackup - + Full-site backup and restore for Joomla — database, files, and configuration. diff --git a/mokosuitebackup.xml b/mokosuitebackup.xml index b80cd9c..060aa4a 100644 --- a/mokosuitebackup.xml +++ b/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Web Services - MokoSuiteBackup - 01.25.00 + 01.25.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/access.xml b/source/packages/com_mokosuitebackup/access.xml index c7e6f78..53fcc84 100644 --- a/source/packages/com_mokosuitebackup/access.xml +++ b/source/packages/com_mokosuitebackup/access.xml @@ -11,5 +11,6 @@ + diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 5b93f7f..60428d2 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -13,7 +13,7 @@ type="FolderPicker" label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR" description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC" - default="administrator/components/com_mokosuitebackup/backups" + default="[DEFAULT_DIR]" addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field" /> - + diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 4aefcf4..72366dd 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -299,6 +299,64 @@ COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store bac COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported." +; Restore modal +COM_MOKOJOOMBACKUP_RESTORE_FILES="Restore files" +COM_MOKOJOOMBACKUP_RESTORE_DATABASE="Restore database" +COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG="Preserve current configuration.php" +COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC="Keep your current database credentials and site paths. Recommended unless you know the backup has the correct credentials." +COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER="Leave blank if archive is not encrypted" + +; Snapshots +COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS="Content Snapshots" +COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE="Content Snapshots" +COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION="Table of content snapshots" +COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE="No snapshots found. Click 'Create Snapshot' to save a snapshot of your content." +COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE="Create Snapshot" +COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE="Restore Snapshot" +COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES="Select content to snapshot" +COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER="e.g. Before redesign, Pre-migration" +COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES="Content Types" +COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES="Articles" +COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC="All articles, frontpage settings, workflow state, and tags" +COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES="Categories" +COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC="Content categories (com_content)" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES="Modules" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC="All modules and their menu assignments" +COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES="Please select at least one content type to snapshot." +COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD="No snapshot selected." +COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE="Restore Mode" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE="Replace (clean)" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC="Remove all existing content of the selected types and replace with snapshot data. This gives you an exact copy of the snapshot state." +COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE="Merge (upsert)" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC="Update existing items by ID and insert missing ones. Content added after the snapshot is preserved." +COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES="Types to restore" +COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING="Replace mode will delete all current content of the selected types before restoring from the snapshot. This cannot be undone." +COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted." +COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted." +COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s" + +; Snapshot ACL +COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots" +COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site." + +; Webcron secret field +COM_MOKOJOOMBACKUP_WEBCRON_GENERATE="Generate" +COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE="No secret set — webcron is disabled." +COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT="Too short — minimum %d characters required." +COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK="Weak — avoid common words like 'password', 'admin', 'secret'." +COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK="Acceptable — consider making it longer for better security." +COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG="Strong secret word." + +; IP whitelist field +COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP="Your current IP" +COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP="Add my IP" +COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED="Included" +COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS="IP Address" +COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE="Remove" +COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger webcron (if secret is correct)." +COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address" +COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add" + ; Errors COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore." diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index 66f029b..ca86657 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> MokoSuiteBackup - 01.25.00 + 01.25.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -45,6 +45,9 @@ COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS + COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS COM_MOKOJOOMBACKUP_SUBMENU_PROFILES diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 70936dc..03e32bf 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` ( KEY `idx_backupstart` (`backupstart`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` ( + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `description` VARCHAR(255) NOT NULL DEFAULT '', + `content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]', + `status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail', + `articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file', + `data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes', + `log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log', + `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + `created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Insert default backup profile (IGNORE prevents duplicate key error on update) INSERT IGNORE INTO `#__mokosuitebackup_profiles` ( `id`, `title`, `description`, `backup_type`, diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql new file mode 100644 index 0000000..20e4f51 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` ( + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `description` VARCHAR(255) NOT NULL DEFAULT '', + `content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]', + `status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail', + `articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file', + `data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes', + `log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log', + `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + `created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php new file mode 100644 index 0000000..3aa1678 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -0,0 +1,179 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; + +class SnapshotsController extends AdminController +{ + protected $text_prefix = 'COM_MOKOJOOMBACKUP_SNAPSHOTS'; + + public function getModel($name = 'Snapshot', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } + + /** + * Create a new content snapshot. + */ + public function create(): void + { + $this->checkToken(); + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $contentTypes = $this->input->get('content_types', [], 'array'); + $description = $this->input->getString('description', ''); + + if (empty($contentTypes)) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $engine = new SnapshotEngine(); + $result = $engine->create($contentTypes, $description); + + if ($result['success']) { + $this->setMessage($result['message']); + } else { + $this->setMessage($result['message'], 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } + + /** + * Restore from a content snapshot. + */ + public function restore(): void + { + $this->checkToken(); + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $id = $this->input->getInt('id', 0); + $mode = $this->input->getCmd('restore_mode', 'replace'); + $contentTypes = $this->input->get('restore_types', [], 'array'); + + // Enforce valid restore mode at controller boundary + if (!in_array($mode, ['replace', 'merge'], true)) { + $mode = 'replace'; + } + + if (!$id) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $engine = new SnapshotRestoreEngine(); + $result = $engine->restore($id, $mode, $contentTypes); + + if ($result['success']) { + $this->setMessage($result['message']); + } else { + $this->setMessage($result['message'], 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } + + /** + * Delete snapshot records and their data files. + */ + public function delete(): void + { + $this->checkToken(); + + if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) { + $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $cid = $this->input->get('cid', [], 'array'); + + if (empty($cid)) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $db = Factory::getDbo(); + $deleted = 0; + $errors = []; + + foreach ($cid as $id) { + $id = (int) $id; + + try { + // Load record to get file path + $query = $db->getQuery(true) + ->select($db->quoteName('data_file')) + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $dataFile = $db->loadResult(); + + // Delete data file + if ($dataFile && is_file($dataFile)) { + if (!unlink($dataFile)) { + error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $dataFile); + } + } + + // Delete record + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $db->execute(); + $deleted++; + } catch (\Exception $e) { + error_log('MokoSuiteBackup: Failed to delete snapshot ' . $id . ': ' . $e->getMessage()); + $errors[] = $id; + } + } + + if (!empty($errors)) { + $this->setMessage( + Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted) + . ' ' . Text::sprintf('COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS', implode(', ', $errors)), + 'warning' + ); + } else { + $this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)); + } + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php new file mode 100644 index 0000000..4ea16a3 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -0,0 +1,238 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Snapshot engine — creates lightweight JSON snapshots of specific content + * types (articles, categories, modules) without touching the filesystem. + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +class SnapshotEngine +{ + private array $log = []; + + /** Content type => tables mapping */ + private const TYPE_TABLES = [ + 'articles' => [ + '#__content', + '#__content_frontpage', + ], + 'categories' => [ + '#__categories', + ], + 'modules' => [ + '#__modules', + '#__modules_menu', + ], + ]; + + /** Related tables always captured when articles are included */ + private const ARTICLE_RELATED = [ + '#__workflow_associations', + '#__contentitem_tag_map', + ]; + + /** + * Create a snapshot of selected content types. + * + * @param array $contentTypes Types to snapshot: articles, categories, modules + * @param string $description User-provided description + * + * @return array{success: bool, message: string, id?: int} + */ + public function create(array $contentTypes, string $description = ''): array + { + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + + if (empty($contentTypes)) { + return ['success' => false, 'message' => 'No content types selected']; + } + + $validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']); + + if (empty($validTypes)) { + return ['success' => false, 'message' => 'No valid content types selected']; + } + + $this->log('Starting snapshot: ' . implode(', ', $validTypes)); + + try { + $data = [ + 'version' => 1, + 'created' => date('Y-m-d H:i:s'), + 'content_types' => array_values($validTypes), + 'tables' => [], + ]; + + $counts = [ + 'articles' => 0, + 'categories' => 0, + 'modules' => 0, + ]; + + // Dump each selected content type + foreach ($validTypes as $type) { + foreach (self::TYPE_TABLES[$type] as $abstractTable) { + $realTable = str_replace('#__', $prefix, $abstractTable); + $rows = $this->dumpTable($db, $realTable, $abstractTable, $type); + $data['tables'][$abstractTable] = $rows; + $this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows'); + } + } + + // Capture related tables for articles + if (in_array('articles', $validTypes)) { + $rows = $this->dumpFilteredTable( + $db, + str_replace('#__', $prefix, '#__workflow_associations'), + '#__workflow_associations', + 'extension', + 'com_content.article' + ); + $data['tables']['#__workflow_associations'] = $rows; + $this->log(' #__workflow_associations: ' . count($rows) . ' rows'); + + $rows = $this->dumpTagMap($db, $prefix); + $data['tables']['#__contentitem_tag_map'] = $rows; + $this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows'); + } + + // Count items + if (in_array('articles', $validTypes)) { + $counts['articles'] = count($data['tables']['#__content'] ?? []); + } + + if (in_array('categories', $validTypes)) { + $counts['categories'] = count($data['tables']['#__categories'] ?? []); + } + + if (in_array('modules', $validTypes)) { + $counts['modules'] = count($data['tables']['#__modules'] ?? []); + } + + // Write JSON file to backup directory + $backupDir = BackupDirectory::getDefaultAbsolute(); + BackupDirectory::ensureReady($backupDir); + + $filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json'; + $filePath = $backupDir . '/' . $filename; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + if ($json === false) { + throw new \RuntimeException('Failed to encode snapshot data as JSON'); + } + + if (file_put_contents($filePath, $json) === false) { + throw new \RuntimeException('Failed to write snapshot file: ' . $filePath); + } + + $fileSize = strlen($json); + $this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)'); + + // Create database record + $now = Factory::getDate()->toSql(); + $userId = Factory::getApplication()->getIdentity()->id ?? 0; + + $record = (object) [ + 'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes), + 'content_types' => json_encode(array_values($validTypes)), + 'status' => 'complete', + 'articles_count' => $counts['articles'], + 'categories_count' => $counts['categories'], + 'modules_count' => $counts['modules'], + 'data_file' => $filePath, + 'data_size' => $fileSize, + 'log' => implode("\n", $this->log), + 'created' => $now, + 'created_by' => $userId, + ]; + + $db->insertObject('#__mokosuitebackup_snapshots', $record, 'id'); + + $this->log('Snapshot record created: ID ' . $record->id); + + return [ + 'success' => true, + 'message' => sprintf( + 'Snapshot created: %d articles, %d categories, %d modules', + $counts['articles'], + $counts['categories'], + $counts['modules'] + ), + 'id' => $record->id, + ]; + } catch (\Exception $e) { + $this->log('FATAL: ' . $e->getMessage()); + + return [ + 'success' => false, + 'message' => 'Snapshot failed: ' . $e->getMessage(), + 'log' => implode("\n", $this->log), + ]; + } + } + + /** + * Dump all rows from a table. + */ + private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array + { + $query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable)); + + // Filter categories to com_content only + if ($abstractTable === '#__categories' && $type === 'categories') { + $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')); + } + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Dump rows from a table filtered by a column value. + */ + private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array + { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($realTable)) + ->where($db->quoteName($column) . ' = ' . $db->quote($value)); + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Dump tag map entries for com_content items. + */ + private function dumpTagMap(object $db, string $prefix): array + { + $table = $prefix . 'contentitem_tag_map'; + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($table)) + ->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%')); + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + private function log(string $message): void + { + $this->log[] = '[' . date('H:i:s') . '] ' . $message; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php new file mode 100644 index 0000000..6bb514f --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -0,0 +1,324 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Restores content from a snapshot JSON file. + * + * Two restore modes: + * - replace: Truncates target tables then inserts all snapshot rows (clean slate) + * - merge: Upserts by primary key — updates existing rows, inserts new ones + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class SnapshotRestoreEngine +{ + private array $log = []; + + /** Primary key columns for each table */ + private const PRIMARY_KEYS = [ + '#__content' => 'id', + '#__content_frontpage' => 'content_id', + '#__categories' => 'id', + '#__workflow_associations' => 'item_id', + '#__contentitem_tag_map' => null, // composite key, handled specially + '#__modules' => 'id', + '#__modules_menu' => null, // composite key, handled specially + ]; + + /** + * Restore from a snapshot record. + * + * @param int $snapshotId Snapshot record ID + * @param string $mode 'replace' or 'merge' + * @param array $contentTypes Which types to restore (empty = all from snapshot) + * + * @return array{success: bool, message: string, log?: string} + */ + public function restore(int $snapshotId, string $mode = 'replace', array $contentTypes = []): array + { + if (!@set_time_limit(0)) { + $this->log('WARNING: Could not disable time limit — large restores may timeout'); + } + + if (!@ini_set('memory_limit', '512M')) { + $this->log('WARNING: Could not increase memory limit to 512M'); + } + + if (!in_array($mode, ['replace', 'merge'])) { + return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode]; + } + + $db = Factory::getDbo(); + + // Load snapshot record + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $snapshotId); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId]; + } + + if ($record->status !== 'complete') { + return ['success' => false, 'message' => 'Cannot restore from failed snapshot']; + } + + if (!is_file($record->data_file) || !is_readable($record->data_file)) { + return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file]; + } + + $this->log('Loading snapshot file: ' . basename($record->data_file)); + + $json = file_get_contents($record->data_file); + + if ($json === false) { + return ['success' => false, 'message' => 'Cannot read snapshot file']; + } + + $data = json_decode($json, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()]; + } + + if (!is_array($data) || empty($data['tables'])) { + return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key']; + } + + $snapshotTypes = $data['content_types'] ?? []; + $this->log('Snapshot contains: ' . implode(', ', $snapshotTypes)); + $this->log('Restore mode: ' . $mode); + + // Determine which types to restore + if (!empty($contentTypes)) { + $restoreTypes = array_intersect($contentTypes, $snapshotTypes); + } else { + $restoreTypes = $snapshotTypes; + } + + if (empty($restoreTypes)) { + return ['success' => false, 'message' => 'No matching content types to restore']; + } + + $this->log('Restoring types: ' . implode(', ', $restoreTypes)); + + $prefix = $db->getPrefix(); + $totalRows = 0; + + try { + $db->transactionStart(); + + // Build list of tables to restore based on selected types + $tablesToRestore = $this->getTablesToRestore($restoreTypes); + + foreach ($tablesToRestore as $abstractTable) { + if (!isset($data['tables'][$abstractTable])) { + $this->log(' Skipping ' . $abstractTable . ' (not in snapshot)'); + continue; + } + + $rows = $data['tables'][$abstractTable]; + $realTable = str_replace('#__', $prefix, $abstractTable); + + if ($mode === 'replace') { + $rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows); + } else { + $rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows); + } + + $totalRows += $rowCount; + $this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored'); + } + + $db->transactionCommit(); + + $this->log('Restore complete: ' . $totalRows . ' total rows'); + + return [ + 'success' => true, + 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)), + 'log' => implode("\n", $this->log), + ]; + } catch (\Throwable $e) { + try { + $db->transactionRollback(); + $this->log('Transaction rolled back'); + } catch (\Exception $rollbackEx) { + $this->log('Rollback failed: ' . $rollbackEx->getMessage()); + } + + $this->log('FATAL: ' . $e->getMessage()); + + return [ + 'success' => false, + 'message' => 'Restore failed: ' . $e->getMessage(), + 'log' => implode("\n", $this->log), + ]; + } + } + + /** + * Replace mode: delete existing rows, then insert all snapshot rows. + */ + private function restoreReplace(object $db, string $realTable, string $abstractTable, array $rows): int + { + // Use DELETE instead of TRUNCATE to stay within transaction + $this->truncateFiltered($db, $realTable, $abstractTable, $rows); + + $count = 0; + + foreach ($rows as $row) { + $obj = (object) $row; + $db->insertObject($realTable, $obj); + $count++; + } + + return $count; + } + + /** + * Merge mode: upsert rows by primary key. + */ + private function restoreMerge(object $db, string $realTable, string $abstractTable, array $rows): int + { + $pk = self::PRIMARY_KEYS[$abstractTable] ?? null; + $count = 0; + + foreach ($rows as $row) { + $obj = (object) $row; + + if ($pk !== null && isset($row[$pk])) { + // Check if row exists + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName($realTable)) + ->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk])) + )->loadResult(); + + if ($exists) { + $db->updateObject($realTable, $obj, $pk); + } else { + $db->insertObject($realTable, $obj); + } + } else { + // Composite key tables — insert, skip genuine duplicates + try { + $db->insertObject($realTable, $obj); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) { + $this->log(' Skipped duplicate in ' . $abstractTable); + continue; + } + + throw $e; + } + } + + $count++; + } + + return $count; + } + + /** + * Delete rows from a table, scoping to relevant content only. + * + * Shared tables (#__categories, #__modules, etc.) are filtered so + * only the rows belonging to our content types are deleted — never + * the entire table. + */ + private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): void + { + $query = $db->getQuery(true)->delete($db->quoteName($realTable)); + + switch ($abstractTable) { + case '#__categories': + $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')); + break; + + case '#__workflow_associations': + $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content.article')); + break; + + case '#__contentitem_tag_map': + $query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%')); + break; + + case '#__modules': + // Only delete modules that exist in the snapshot — never wipe all site modules + $ids = array_filter(array_column($rows, 'id')); + + if (empty($ids)) { + return; + } + + $ids = array_map('intval', $ids); + $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')'); + break; + + case '#__modules_menu': + // Only delete menu assignments for modules in the snapshot + $moduleIds = array_filter(array_column($rows, 'moduleid')); + + if (empty($moduleIds)) { + return; + } + + $moduleIds = array_map('intval', array_unique($moduleIds)); + $query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')'); + break; + + // #__content and #__content_frontpage are fully owned by com_content + default: + break; + } + + $db->setQuery($query); + $db->execute(); + } + + /** + * Build list of abstract table names for the given content types. + */ + private function getTablesToRestore(array $types): array + { + $tables = []; + + if (in_array('articles', $types)) { + $tables[] = '#__content'; + $tables[] = '#__content_frontpage'; + $tables[] = '#__workflow_associations'; + $tables[] = '#__contentitem_tag_map'; + } + + if (in_array('categories', $types)) { + $tables[] = '#__categories'; + } + + if (in_array('modules', $types)) { + $tables[] = '#__modules'; + $tables[] = '#__modules_menu'; + } + + return array_unique($tables); + } + + private function log(string $message): void + { + $this->log[] = '[' . date('H:i:s') . '] ' . $message; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php b/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php new file mode 100644 index 0000000..0d4c1df --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php @@ -0,0 +1,203 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Custom field for IP whitelist management. + * Shows current user's IP and presents entries as a table. + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class IpWhitelistField extends FormField +{ + protected $type = 'IpWhitelist'; + + protected function getInput(): string + { + $value = trim($this->value ?? ''); + $id = $this->id; + $name = $this->name; + $currentIp = $this->getCurrentIp(); + + $ips = array_filter(array_map('trim', explode(',', $value))); + + $html = ''; + + // Current IP display + $html .= '
' + . ' ' + . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP') . ': ' + . '' . htmlspecialchars($currentIp) . ''; + + $alreadyAdded = in_array($currentIp, $ips); + if (!$alreadyAdded) { + $html .= ' '; + } else { + $html .= ' ' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED') . ''; + } + + $html .= '
'; + + // IP table + $html .= ''; + $html .= '' + . '' + . '' + . ''; + $html .= ''; + + if (empty($ips)) { + $html .= ''; + } else { + foreach ($ips as $ip) { + $html .= '' + . '' + . '' + . ''; + } + } + + $html .= '
' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS') . '' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE') . '
' + . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE') + . '
' . htmlspecialchars($ip) . '' + . '
'; + + // Add custom IP + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= $this->getScript(); + + return $html; + } + + private function getCurrentIp(): string + { + $app = Factory::getApplication(); + + // Try standard header first, then forwarded headers + $ip = $app->input->server->getString('REMOTE_ADDR', ''); + + // Check forwarded headers (common behind reverse proxies) + $forwarded = $app->input->server->getString('HTTP_X_FORWARDED_FOR', ''); + + if (!empty($forwarded)) { + // Take the first IP in the chain (client IP) + $parts = explode(',', $forwarded); + $candidate = trim($parts[0]); + + if (filter_var($candidate, FILTER_VALIDATE_IP)) { + $ip = $candidate; + } + } + + return $ip ?: '0.0.0.0'; + } + + private function getScript(): string + { + $noneText = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE')); + + return << +function mokoIpGetList(fieldId) { + var val = document.getElementById(fieldId).value.trim(); + return val ? val.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : []; +} + +function mokoIpSync(fieldId, ips) { + document.getElementById(fieldId).value = ips.join(', '); + mokoIpRebuildTable(fieldId, ips); +} + +function mokoIpRebuildTable(fieldId, ips) { + var tbody = document.querySelector('#' + fieldId + '-table tbody'); + while (tbody.firstChild) tbody.removeChild(tbody.firstChild); + + if (ips.length === 0) { + var tr = document.createElement('tr'); + tr.className = 'moko-ip-empty'; + var td = document.createElement('td'); + td.colSpan = 2; + td.className = 'text-muted text-center'; + td.textContent = {$noneText}; + tr.appendChild(td); + tbody.appendChild(tr); + return; + } + + ips.forEach(function(ip) { + var tr = document.createElement('tr'); + + var tdIp = document.createElement('td'); + tdIp.className = 'font-monospace'; + tdIp.textContent = ip; + tr.appendChild(tdIp); + + var tdAct = document.createElement('td'); + tdAct.className = 'text-center'; + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-outline-danger'; + btn.onclick = function() { mokoIpRemove(fieldId, ip); }; + var span = document.createElement('span'); + span.className = 'icon-times'; + btn.appendChild(span); + tdAct.appendChild(btn); + tr.appendChild(tdAct); + + tbody.appendChild(tr); + }); +} + +function mokoIpAdd(fieldId, ip) { + var ips = mokoIpGetList(fieldId); + if (ips.indexOf(ip) === -1) { + ips.push(ip); + mokoIpSync(fieldId, ips); + } +} + +function mokoIpRemove(fieldId, ip) { + var ips = mokoIpGetList(fieldId).filter(function(i) { return i !== ip; }); + mokoIpSync(fieldId, ips); +} + +function mokoIpAddCustom(fieldId) { + var input = document.getElementById(fieldId + '-new'); + var ip = input.value.trim(); + if (!ip) return; + mokoIpAdd(fieldId, ip); + input.value = ''; +} + +JS; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php b/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php new file mode 100644 index 0000000..1be7aff --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php @@ -0,0 +1,184 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Custom field for the webcron secret word. + * Generates a random default and validates minimum strength. + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class WebcronSecretField extends FormField +{ + protected $type = 'WebcronSecret'; + + private const MIN_LENGTH = 16; + private const WEAK_PATTERNS = [ + 'password', 'secret', '123456', 'admin', 'backup', + 'test', 'webcron', 'qwerty', 'letmein', 'welcome', + ]; + + protected function getInput(): string + { + $value = $this->value ?? ''; + $id = $this->id; + $name = $this->name; + $maxLength = (int) ($this->element['maxlength'] ?? 64); + + $strengthHtml = ''; + $strengthClass = 'text-muted'; + $strengthText = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE'); + + if (!empty($value)) { + $strength = $this->evaluateStrength($value); + $strengthClass = $strength['class']; + $strengthText = $strength['label']; + } + + $html = '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + $html .= '
' + . $strengthText . '
'; + + $html .= $this->getScript(); + + return $html; + } + + private function evaluateStrength(string $value): array + { + $len = strlen($value); + + // Check weak patterns + $lower = strtolower($value); + foreach (self::WEAK_PATTERNS as $weak) { + if (str_contains($lower, $weak)) { + return [ + 'class' => 'text-danger fw-bold', + 'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'), + ]; + } + } + + if ($len < self::MIN_LENGTH) { + return [ + 'class' => 'text-danger', + 'label' => Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', self::MIN_LENGTH), + ]; + } + + $hasUpper = preg_match('/[A-Z]/', $value); + $hasLower = preg_match('/[a-z]/', $value); + $hasDigit = preg_match('/[0-9]/', $value); + + if ($hasUpper && $hasLower && $hasDigit && $len >= 32) { + return [ + 'class' => 'text-success', + 'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'), + ]; + } + + if ($hasUpper && $hasLower && $hasDigit) { + return [ + 'class' => 'text-warning', + 'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'), + ]; + } + + return [ + 'class' => 'text-danger', + 'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'), + ]; + } + + private function getScript(): string + { + $minLen = self::MIN_LENGTH; + $weakJson = json_encode(self::WEAK_PATTERNS); + $labelNone = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE'); + $labelShort = json_encode(Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', $minLen)); + $labelWeak = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK')); + $labelOk = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK')); + $labelStrong = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG')); + + return << +function mokoWebcronGenerate(fieldId) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var result = ''; + var arr = new Uint8Array(32); + (window.crypto || window.msCrypto).getRandomValues(arr); + for (var i = 0; i < 32; i++) { + result += chars.charAt(arr[i] % chars.length); + } + var field = document.getElementById(fieldId); + field.value = result; + mokoWebcronCheckStrength(field); +} + +function mokoWebcronCheckStrength(field) { + var val = field.value; + var el = document.getElementById(field.id + '-strength'); + var weak = {$weakJson}; + var lower = val.toLowerCase(); + + if (!val) { + el.className = 'small mt-1 text-muted'; + el.textContent = '{$labelNone}'; + return; + } + + for (var i = 0; i < weak.length; i++) { + if (lower.indexOf(weak[i]) !== -1) { + el.className = 'small mt-1 text-danger fw-bold'; + el.textContent = {$labelWeak}; + return; + } + } + + if (val.length < {$minLen}) { + el.className = 'small mt-1 text-danger'; + el.textContent = {$labelShort}; + return; + } + + var hasUpper = /[A-Z]/.test(val); + var hasLower = /[a-z]/.test(val); + var hasDigit = /[0-9]/.test(val); + + if (hasUpper && hasLower && hasDigit && val.length >= 32) { + el.className = 'small mt-1 text-success'; + el.textContent = {$labelStrong}; + } else if (hasUpper && hasLower && hasDigit) { + el.className = 'small mt-1 text-warning'; + el.textContent = {$labelOk}; + } else { + el.className = 'small mt-1 text-danger'; + el.textContent = {$labelWeak}; + } +} + +JS; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php b/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php new file mode 100644 index 0000000..366bd5e --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php @@ -0,0 +1,37 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class SnapshotModel extends BaseDatabaseModel +{ + /** + * Get a single snapshot record. + * + * @param int $pk Primary key + * + * @return object|null + */ + public function getItem(int $pk = 0): ?object + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . (int) $pk); + $db->setQuery($query); + + return $db->loadObject() ?: null; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php b/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php new file mode 100644 index 0000000..26b37e9 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\ListModel; + +class SnapshotsModel extends ListModel +{ + public function __construct($config = []) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', 'a.id', + 'description', 'a.description', + 'created', 'a.created', + 'data_size', 'a.data_size', + ]; + } + + parent::__construct($config); + } + + protected function getListQuery() + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokosuitebackup_snapshots', 'a')); + + // Search filter + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = $db->quote('%' . $db->escape($search, true) . '%'); + $query->where($db->quoteName('a.description') . ' LIKE ' . $search); + } + + // Ordering + $orderCol = $this->state->get('list.ordering', 'a.created'); + $orderDirn = $this->state->get('list.direction', 'DESC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn)); + + return $query; + } + + protected function populateState($ordering = 'a.created', $direction = 'DESC') + { + $search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string'); + $this->setState('filter.search', $search); + + parent::populateState($ordering, $direction); + } +} diff --git a/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php new file mode 100644 index 0000000..089e7e4 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php @@ -0,0 +1,55 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\View\Snapshots; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $items; + protected $pagination; + protected $state; + + public function display($tpl = null): void + { + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $user = Factory::getApplication()->getIdentity(); + + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera'); + + if ($user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + ToolbarHelper::custom('snapshots.create', 'plus', '', 'COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE', false); + } + + if ($user->authorise('core.delete', 'com_mokosuitebackup')) { + ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'snapshots.delete'); + } + + if ($user->authorise('core.admin', 'com_mokosuitebackup')) { + ToolbarHelper::preferences('com_mokosuitebackup'); + } + } +} diff --git a/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html b/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index eebb683..c7a4bf6 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -311,6 +311,34 @@ $listDirn = $this->escape($this->state->get('list.direction')); // Expose for toolbar button window.mokosuitebackupStart = startSteppedBackup; + // Intercept Restore toolbar button to show the modal + document.addEventListener('DOMContentLoaded', function() { + var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload'); + if (restoreBtn) { + restoreBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + // Get selected record from checkboxes + var checked = document.querySelectorAll('input[name="cid[]"]:checked'); + if (checked.length === 0) { + alert(''); + return false; + } + document.getElementById('mb-restore-record-id').value = checked[0].value; + document.getElementById('mb-restore-modal').style.display = 'block'; + return false; + }, true); + } + }); + + // Close restore modal + document.addEventListener('click', function(e) { + if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') { + document.getElementById('mb-restore-modal').style.display = 'none'; + } + }); + // View Log modal handler document.addEventListener('click', function(e) { var btn = e.target.closest('.mb-view-log'); @@ -353,6 +381,61 @@ $listDirn = $this->escape($this->state->get('list.direction')); })(); + + +