diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 41e81ee..88620a1 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -5,7 +5,7 @@ Package - MokoJoomBackup MokoConsulting Full-site backup and restore for Joomla — database, files, and configuration - 01.00.00 + 01.01.07-dev GNU General Public License v3 diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml new file mode 100644 index 0000000..33aff71 --- /dev/null +++ b/.mokogitea/workflows/auto-bump.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.mokogitea/workflows/auto-bump.yml +# VERSION: 09.23.00 +# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) + +name: "Universal: Auto Version Bump" + +on: + push: + branches: + - dev + - rc + - 'feature/**' + - 'patch/**' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +permissions: + contents: write + +jobs: + bump: + name: Version Bump + runs-on: release + if: >- + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip bump]') && + !startsWith(github.event.head_commit.message, 'Merge pull request') + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup moko-platform tools + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + if [ -d "/opt/moko-platform/cli" ]; then + echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" + fi + + - name: Bump version + run: | + php ${MOKO_CLI}/version_auto_bump.php \ + --path . --branch "${GITHUB_REF_NAME}" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 78dec4b..141fdcc 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -102,13 +102,14 @@ jobs: run: | php /tmp/moko-platform-api/cli/release_publish.php \ --path . --stability rc --bump minor --branch rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream - name: Summary if: always() run: | echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY - echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY + echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY # ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── release: @@ -167,7 +168,8 @@ jobs: run: | php /tmp/moko-platform-api/cli/release_publish.php \ --path . --stability stable --bump minor --branch main \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- - name: "Step 9: Mirror release to GitHub" diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml new file mode 100644 index 0000000..67a735f --- /dev/null +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoPlatform.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.mokogitea/workflows/branch-cleanup.yml +# VERSION: 09.23.00 +# BRIEF: Delete feature branches after PR merge + +name: "Branch Cleanup" + +on: + pull_request: + types: [closed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + cleanup: + name: Delete merged branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.ref != 'dev' && + github.event.pull_request.head.ref != 'main' + + steps: + - name: Delete source branch + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API}/${ENCODED}" 2>/dev/null || true) + + if [ "$STATUS" = "204" ]; then + echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + elif [ "$STATUS" = "404" ]; then + echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})" + fi diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 29ca4d4..70521b3 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/cleanup.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs name: "Universal: Repository Cleanup" diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml index e0fdd1d..9126c91 100644 --- a/.mokogitea/workflows/gitleaks.yml +++ b/.mokogitea/workflows/gitleaks.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Security # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/gitleaks.yml.template -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # # +========================================================================+ diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml new file mode 100644 index 0000000..825b392 --- /dev/null +++ b/.mokogitea/workflows/issue-branch.yml @@ -0,0 +1,73 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Automation +# VERSION: 01.01.07 +# BRIEF: Auto-create feature branch when an issue is opened + +name: "Universal: Issue Branch" + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +jobs: + create-branch: + name: Create feature branch + runs-on: ubuntu-latest + steps: + - name: Create branch and comment + run: | + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + ISSUE_NUM="${{ github.event.issue.number }}" + ISSUE_TITLE="${{ github.event.issue.title }}" + + # Build slug from title: lowercase, replace non-alnum with dash, trim + SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40) + BRANCH="feature/${ISSUE_NUM}-${SLUG}" + + # Check dev branch exists + DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${API}/branches/dev" 2>/dev/null || echo "000") + + if [ "${DEV_EXISTS}" != "200" ]; then + echo "No dev branch -- skipping" + exit 0 + fi + + # Create branch from dev + HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/branches" \ + -d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000") + + if [ "${HTTP}" = "201" ]; then + echo "Created branch: ${BRANCH}" + + # Comment on issue with branch link + REPO_URL="${GITEA_URL}/${{ github.repository }}" + BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`" + + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/issues/${ISSUE_NUM}/comments" \ + -d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1 + + echo "Commented on issue #${ISSUE_NUM}" + else + echo "Failed to create branch (HTTP ${HTTP}) -- may already exist" + fi diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index cde4541..c18b809 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Notifications # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/notify.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Push notifications via ntfy on release success or workflow failure name: "Universal: Notifications" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index bf72613..6625857 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -105,6 +105,19 @@ jobs: - 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: | @@ -134,6 +147,98 @@ jobs: 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 }}" @@ -151,6 +256,13 @@ jobs: 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) @@ -183,6 +295,138 @@ jobs: ;; 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 diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 0000000..ff818ba --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,224 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 09.23.00 +# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch + +name: "Universal: Pre-Release" + +on: + pull_request: + types: [closed] + branches: + - dev + workflow_dispatch: + inputs: + stability: + description: 'Pre-release channel' + required: true + type: choice + options: + - development + - alpha + - beta + - release-candidate + +permissions: + contents: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +jobs: + build: + name: "Build Pre-Release (${{ inputs.stability || 'development' }})" + runs-on: release + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.MOKOGITEA_TOKEN }} + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + # Always fetch latest CLI tools — never use stale cache from previous runs + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" + + - name: Detect platform + id: platform + run: | + php ${MOKO_CLI}/manifest_read.php --path . --github-output + + - name: Resolve metadata and bump version + id: meta + run: | + STABILITY="${{ inputs.stability || 'development' }}" + + case "$STABILITY" in + development) TAG="development" ;; + alpha) TAG="alpha" ;; + beta) TAG="beta" ;; + release-candidate) TAG="release-candidate" ;; + esac + + # Set stability suffix, bump preserves it, fix consistency + php ${MOKO_CLI}/version_set_platform.php \ + --path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \ + --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true + php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true + + # Read final version (includes suffix, e.g. 01.02.15-dev) + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) + [ -z "$VERSION" ] && VERSION="00.00.01" + + # Commit version bump + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" + git push origin HEAD 2>&1 + } + + # Auto-detect element via manifest_element.php + php ${MOKO_CLI}/manifest_element.php \ + --path . --version "$VERSION" --stability "$STABILITY" \ + --repo "${GITEA_REPO}" --github-output + + # Read back element outputs + EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + + echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ===" + + - name: Create release + id: release + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_create.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --branch dev --prerelease + + - name: Build package and upload + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_package.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --output /tmp || true + + - name: Update updates.xml + if: steps.platform.outputs.platform == 'joomla' + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + + if [ ! -f "updates.xml" ]; then + echo "No updates.xml -- skipping" + exit 0 + fi + + SHA_FLAG="" + [ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}" + + php ${MOKO_CLI}/updates_xml_build.php \ + --path . --version "${VERSION}" --stability "${STABILITY}" \ + --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ + ${SHA_FLAG} + + # Commit and push + if ! git diff --quiet updates.xml 2>/dev/null; then + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git add updates.xml + git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]" + git push origin HEAD 2>&1 || echo "WARNING: push failed" + fi + + - name: "Sync updates.xml to all branches" + if: steps.platform.outputs.platform == 'joomla' + run: | + CURRENT_BRANCH="${{ github.ref_name }}" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + + for BRANCH in main dev; do + [ "$BRANCH" = "$CURRENT_BRANCH" ] && continue + echo "Syncing updates.xml -> ${BRANCH}" + git fetch origin "${BRANCH}" 2>/dev/null || continue + git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue + git checkout "${CURRENT_BRANCH}" -- updates.xml + if ! git diff --quiet updates.xml 2>/dev/null; then + git add updates.xml + git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]" + git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed" + fi + git checkout "${CURRENT_BRANCH}" 2>/dev/null + done + + - name: "Delete lesser pre-release channels (cascade)" + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + php ${MOKO_CLI}/release_cascade.php \ + --stability "${{ steps.meta.outputs.stability }}" \ + --token "${TOKEN}" \ + --api-base "${API_BASE}" + + - name: Summary + if: always() + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY + echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml index 714d407..1bd9470 100644 --- a/.mokogitea/workflows/security-audit.yml +++ b/.mokogitea/workflows/security-audit.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Security # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/security-audit.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Dependency vulnerability scanning for composer and npm packages name: "Universal: Security Audit" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db115d..009ae6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,62 +2,55 @@ ## [Unreleased] -## 01.00.00 — 2026-06-02 +## 01.01 — 2026-06-04 + +### Added +- Admin dashboard view as default landing page with status cards, quick actions, and system health checklist (#28) +- Console plugin (plg_console_mokobackup) — CLI commands: run, list, profiles, restore, cleanup (#29) +- Content plugin (plg_content_mokobackup) — auto-backup before extension install/update (#30) +- Actionlog plugin (plg_actionlog_mokobackup) — logs backup and profile actions to User Action Logs (#31) +- BackupEngine dispatches onMokoBackupAfterRun event for plugin listeners +- Update site notice on dashboard and post-install + +### Changed +- Renamed Kickstart to MokoRestore throughout + +### Fixed +- SQL update migration and error handling +- Removed orphaned scriptfile from component manifest +- Consolidated admin files into single files block + +## 01.00 — 2026-06-02 ### Added - Initial package structure with component, system plugin, task plugin, and webservices plugin - Joomla Scheduled Tasks integration (plg_task_mokobackup) — create multiple tasks, each running a different backup profile on its own schedule - Individual form fields for all profile settings (no raw JSON) - FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification -- Google Drive uploader using OAuth2 refresh tokens and resumable upload API (5 MB chunks) +- Google Drive uploader using OAuth2 refresh tokens and resumable upload API +- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16) - RemoteUploaderInterface for pluggable storage backends -- Remote upload integrated into BackupEngine as Step 3 after archive creation -- Option to delete local copy after successful remote upload (per-profile setting) +- Remote upload integrated into BackupEngine with option to delete local copy after upload - Restore engine with file restoration and database import -- Standalone Kickstart restore script (restore.php) — self-contained site restoration without Joomla, like Akeeba Kickstart -- "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip -- FileRestorer class with protected file handling (preserves configuration.php, .htaccess) +- MokoRestore standalone restore script — self-contained site restoration without Joomla +- "Include Restore Script" toggle per profile +- FileRestorer with protected file handling (preserves configuration.php, .htaccess) - DatabaseImporter with streaming line-by-line SQL execution and error tolerance - Admin dashboard quickicon widget — backup status at a glance with warnings (#18) - Differential backups — only back up files changed since last full backup (#19) -- DifferentialScanner: builds file manifests (path/size/mtime) and compares against base -- File manifest stored in backup record for future differential comparisons -- Automatic full-backup fallback when no base manifest exists +- DifferentialScanner with file manifests stored in backup records - JPA archive format import for Akeeba Backup migration (#20) -- JpaUnarchiver: parses Akeeba JPA binary format (headers, gzip, permissions) -- RestoreEngine auto-detects JPA vs ZIP format - AES-256 archive encryption with per-profile password (#17) -- Encrypted archive support in RestoreEngine (password parameter) -- Encrypted archive support in Kickstart restore.php (password field in UI) -- SHA-256 checksum computed and stored after archive creation (#15) -- "Verify Integrity" toolbar button re-computes hash and compares against stored checksum -- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16) -- S3 uploader with AWS Signature V4, single PUT for files <= 100 MB, multipart for larger -- S3 fields in profile form with showon conditional visibility -- Akeeba importer now maps S3 credentials from Akeeba profiles +- SHA-256 checksum verification for backup integrity (#15) - Email notifications on backup success/failure via Joomla mailer (#14) -- Per-profile notification settings: recipient emails, notify on success/failure -- Failure emails include last 30 lines of backup log for debugging -- mcp_mokobackup MCP server updated with MokoBackupClient for dual-backend support (#21) -- Akeeba Backup Pro importer — imports profiles, filters, remote storage settings, and backup history +- Akeeba Backup Pro importer — profiles, filters, remote storage, and backup history - Auto-disables Akeeba plugins and scheduled tasks after successful import -- "Import from Akeeba" toolbar button in Profiles view (only shown when Akeeba tables detected) -- Supports both INI-format and JSON-format Akeeba configuration parsing -- Maps Akeeba filter format (per-root, nested) to newline-separated exclusion fields -- Profile selector dropdown in Backup Records view for choosing which profile to run - AJAX step-based backup engine for shared hosting (overcomes max_execution_time) -- SteppedBackupEngine: breaks backup into per-table DB dumps and file batches -- SteppedSession: persistent state between AJAX requests via temp JSON files - Progress bar modal in admin UI with real-time phase/percentage updates -- AjaxController for init/step endpoints with CSRF protection - Per-profile archive settings: format, compression level, split size, backup directory -- Backup engine with step-based execution for large sites -- Database dumper with table-level granularity -- File scanner with directory exclusion filters -- ZIP archive builder +- Backup engine with database dumper, file scanner, and ZIP archive builder - Backup profiles with independent configurations - Backup record management (list, download, delete) -- Admin dashboard with backup history - CLI script for cron/scheduled backups - REST API compatible with MokoBackup MCP server - System plugin for automatic backup cleanup with configurable retention diff --git a/Makefile b/Makefile index 50e3eae..df8aa3e 100644 --- a/Makefile +++ b/Makefile @@ -3,43 +3,29 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # MokoJoomBackup — Full-site backup and restore for Joomla +# +# Builds and releases are handled by CI workflows (pre-release.yml, +# auto-release.yml). This Makefile provides local validation helpers +# and workflow dispatch shortcuts. # ============================================================================== -# CONFIGURATION - Customize these for your extension +# CONFIGURATION # ============================================================================== -# Extension Configuration EXTENSION_NAME := mokobackup EXTENSION_TYPE := package -# Options: module, plugin, component, package, template -EXTENSION_VERSION := 1.0.0 -# Module Configuration (for modules only) -MODULE_TYPE := site -# Options: site, admin - -# Plugin Configuration (for plugins only) -PLUGIN_GROUP := system -# Options: system, content, user, authentication, etc. - -# Directories SRC_DIR := src -BUILD_DIR := build -DIST_DIR := dist -DOCS_DIR := docs -# Joomla Installation (for local testing - customize paths) -JOOMLA_ROOT := /var/www/html/joomla -JOOMLA_VERSION := 4 +# Gitea +GITEA_URL := https://git.mokoconsulting.tech +GITEA_ORG := MokoConsulting +GITEA_REPO := MokoJoomBackup # Tools PHP := php COMPOSER := composer -NPM := npm PHPCS := vendor/bin/phpcs -PHPCBF := vendor/bin/phpcbf -PHPUNIT := vendor/bin/phpunit -ZIP := zip # Coding Standards PHPCS_STANDARD := Joomla @@ -58,146 +44,122 @@ COLOR_RED := \033[31m .PHONY: help help: ## Show this help message @echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)" - @echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)" + @echo "$(COLOR_BLUE)║ MokoJoomBackup Makefile ║$(COLOR_RESET)" @echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)" @echo "" - @echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)" - @echo "" @echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}' @echo "" -.PHONY: install-deps -install-deps: ## Install all dependencies (Composer + npm) - @echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)" - @if [ -f "composer.json" ]; then \ - $(COMPOSER) install; \ - echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \ - fi +# -- Validation ---------------------------------------------------------------- .PHONY: lint -lint: ## Run PHP linter (syntax check) +lint: ## Run PHP syntax check on all source files @echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)" - @find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \ - -exec $(PHP) -l {} \; | grep -v "No syntax errors" || true + @ERROR=0; \ + find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -v "No syntax errors" || true; \ + if find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -q "Parse error"; then \ + echo "$(COLOR_RED)✗ Syntax errors found$(COLOR_RESET)"; exit 1; \ + fi @echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)" .PHONY: phpcs phpcs: ## Run PHP CodeSniffer (Joomla standards) @echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)" @if [ -f "$(PHPCS)" ]; then \ - $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \ + $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php $(SRC_DIR); \ else \ - echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \ + echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \ fi .PHONY: validate -validate: lint phpcs ## Run all validation checks - @echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)" +validate: lint ## Run all local validation checks + @echo "$(COLOR_GREEN)✓ Validation passed$(COLOR_RESET)" -.PHONY: clean -clean: ## Clean build artifacts - @echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)" - @rm -rf $(BUILD_DIR) $(DIST_DIR) - @echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)" +.PHONY: validate-xml +validate-xml: ## Validate all XML manifests are well-formed + @echo "$(COLOR_BLUE)Validating XML manifests...$(COLOR_RESET)" + @ERROR=0; \ + for f in $$(find $(SRC_DIR) -name "*.xml"); do \ + $(PHP) -r "new SimpleXMLElement(file_get_contents('$$f'));" 2>/dev/null \ + || { echo "$(COLOR_RED)✗ Invalid XML: $$f$(COLOR_RESET)"; ERROR=1; }; \ + done; \ + [ $$ERROR -eq 0 ] && echo "$(COLOR_GREEN)✓ All XML manifests valid$(COLOR_RESET)" || exit 1 + +# -- Dependencies -------------------------------------------------------------- + +.PHONY: install-deps +install-deps: ## Install PHP dependencies via Composer + @echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)" + @if [ -f "composer.json" ]; then \ + $(COMPOSER) install; \ + echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \ + fi + +.PHONY: security-check +security-check: ## Run security audit on dependencies + @echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)" + @if [ -f "composer.json" ]; then \ + $(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \ + fi + +# -- Minify -------------------------------------------------------------------- MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform)) MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js .PHONY: minify minify: ## Minify CSS/JS assets - @echo "Minifying assets..." + @echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)" @if [ -f "$(MINIFY_SCRIPT)" ]; then \ node "$(MINIFY_SCRIPT)" $(SRC_DIR); \ elif [ -f "scripts/minify.js" ]; then \ node scripts/minify.js; \ else \ - echo "No minify script found"; \ + echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \ fi -.PHONY: build -build: clean validate minify ## Build extension package - @echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)" - @mkdir -p $(DIST_DIR) $(BUILD_DIR) - - # Determine package prefix based on extension type - @case "$(EXTENSION_TYPE)" in \ - module) \ - PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \ - BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \ - ;; \ - plugin) \ - PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \ - BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \ - ;; \ - component) \ - PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \ - BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \ - ;; \ - package) \ - PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \ - BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \ - ;; \ - template) \ - PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \ - BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \ - ;; \ - *) \ - echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \ - exit 1; \ - ;; \ - esac; \ - \ - mkdir -p "$$BUILD_TARGET"; \ - \ - echo "Building $$PACKAGE_PREFIX..."; \ - \ - rsync -av --progress \ - --exclude='$(BUILD_DIR)' \ - --exclude='$(DIST_DIR)' \ - --exclude='.git*' \ - --exclude='vendor/' \ - --exclude='node_modules/' \ - --exclude='tests/' \ - --exclude='Makefile' \ - --exclude='composer.json' \ - --exclude='composer.lock' \ - --exclude='package.json' \ - --exclude='package-lock.json' \ - --exclude='phpunit.xml' \ - --exclude='*.md' \ - --exclude='.editorconfig' \ - . "$$BUILD_TARGET/"; \ - \ - cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \ - \ - echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)" - -.PHONY: package -package: build ## Alias for build - @echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)" +# -- Release (CI workflow dispatch) -------------------------------------------- .PHONY: release -release: validate build ## Create a release (validate + build) - @echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)" +release: validate validate-xml ## Trigger pre-release build via CI workflow + @echo "$(COLOR_BLUE)Triggering pre-release workflow...$(COLOR_RESET)" + @if ! command -v curl >/dev/null 2>&1; then \ + echo "$(COLOR_RED)✗ curl required$(COLOR_RESET)"; exit 1; \ + fi + @if [ -z "$$MOKOGITEA_TOKEN" ]; then \ + echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + curl -sf -X POST \ + -H "Authorization: token $$MOKOGITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \ + -d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"development\"}}" \ + && echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (development channel)$(COLOR_RESET)" \ + || { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; } + +.PHONY: release-rc +release-rc: validate validate-xml ## Trigger release-candidate build via CI workflow + @echo "$(COLOR_BLUE)Triggering RC pre-release workflow...$(COLOR_RESET)" + @if [ -z "$$MOKOGITEA_TOKEN" ]; then \ + echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + curl -sf -X POST \ + -H "Authorization: token $$MOKOGITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \ + -d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"release-candidate\"}}" \ + && echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (release-candidate channel)$(COLOR_RESET)" \ + || { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; } + +# -- Info ---------------------------------------------------------------------- .PHONY: version -version: ## Display version information - @echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)" - @echo " Name: $(EXTENSION_NAME)" - @echo " Type: $(EXTENSION_TYPE)" - @echo " Version: $(EXTENSION_VERSION)" - -.PHONY: security-check -security-check: ## Run security checks on dependencies - @echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)" - @if [ -f "composer.json" ]; then \ - $(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \ - fi - -.PHONY: all -all: install-deps validate build ## Run complete build pipeline - @echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)" +version: ## Display version from package manifest + @VERSION=$$(grep '' $(SRC_DIR)/pkg_mokobackup.xml | sed 's/.*\(.*\)<\/version>.*/\1/'); \ + echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))" # Default target .DEFAULT_GOAL := help diff --git a/README.md b/README.md index ec51852..547489b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoJoomBackup - + Full-site backup and restore for Joomla — database, files, and configuration. diff --git a/src/language/en-GB/pkg_mokobackup.sys.ini b/src/language/en-GB/pkg_mokobackup.sys.ini index 071172a..75457fd 100644 --- a/src/language/en-GB/pkg_mokobackup.sys.ini +++ b/src/language/en-GB/pkg_mokobackup.sys.ini @@ -7,3 +7,4 @@ PKG_MOKOBACKUP="Package - MokoJoomBackup" PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." +PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." diff --git a/src/language/en-US/pkg_mokobackup.sys.ini b/src/language/en-US/pkg_mokobackup.sys.ini index 9a32545..4936ac7 100644 --- a/src/language/en-US/pkg_mokobackup.sys.ini +++ b/src/language/en-US/pkg_mokobackup.sys.ini @@ -7,3 +7,4 @@ PKG_MOKOBACKUP="Package - MokoJoomBackup" PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." +PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." diff --git a/src/packages/com_mokobackup/config.xml b/src/packages/com_mokobackup/config.xml new file mode 100644 index 0000000..d1a0011 --- /dev/null +++ b/src/packages/com_mokobackup/config.xml @@ -0,0 +1,108 @@ + + + +
+ + + + + + + + +
+ +
+ + +
+ +
+ + + + + + + + + +
+ +
+ +
+
diff --git a/src/packages/com_mokobackup/forms/profile.xml b/src/packages/com_mokobackup/forms/profile.xml index f712686..123e01c 100644 --- a/src/packages/com_mokobackup/forms/profile.xml +++ b/src/packages/com_mokobackup/forms/profile.xml @@ -39,6 +39,7 @@ default="zip" > + + @@ -114,30 +124,29 @@
@@ -176,6 +185,14 @@ maxlength="512" hint="admin@example.com, backup@example.com" /> + Update Site with your download key." +COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server." +COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." + +; Component Options (config.xml) +COM_MOKOBACKUP_CONFIG_GENERAL="General" +COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory" +COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile." +COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile" +COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified." +COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice" +COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view." +COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults" +COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" +COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command." +COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count" +COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain." +COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications" +COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)" +COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this." +COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success" +COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)." +COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure" +COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)." + +; Folder picker +COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" + +; Exclude fields +COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude." +COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data" +COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure" +COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name" + +; User group notifications +COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" +COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above." + +; Dashboard warnings +COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root" +COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security." + ; Errors COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore." diff --git a/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini b/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini index 2f000f1..8e72912 100644 --- a/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini +++ b/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini @@ -6,10 +6,64 @@ COM_MOKOBACKUP="MokoJoomBackup" COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" +COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard" COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" +COM_MOKOBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard" +COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP="Last Backup" +COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS="No backups yet" +COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled" +COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled" +COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups" +COM_MOKOBACKUP_DASHBOARD_STORAGE="Storage Used" +COM_MOKOBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)" +COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions" +COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks" +COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE="Update Site" +COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health" COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records" COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles" COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your Update Site with your download key." +COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server." +COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." +COM_MOKOBACKUP_CONFIG_GENERAL="General" +COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory" +COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile." +COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile" +COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified." +COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice" +COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view." +COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults" +COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" +COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command." +COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count" +COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain." +COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications" +COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)" +COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this." +COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success" +COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)." +COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure" +COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)." +COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" +COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root" +COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security." +COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" +COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude." +COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data" +COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure" +COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name" +COM_MOKOBACKUP_VIEW_LOG="Backup Log" +COM_MOKOBACKUP_FIELD_CHECKSUM="SHA-256 Checksum" +COM_MOKOBACKUP_FIELD_PATH="File Path" +COM_MOKOBACKUP_FIELD_DB_SIZE="DB Size" +COM_MOKOBACKUP_FIELD_REMOTE="Remote Path" +COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" +COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above." diff --git a/src/packages/com_mokobackup/mokobackup.xml b/src/packages/com_mokobackup/mokobackup.xml index 131212e..9bf599f 100644 --- a/src/packages/com_mokobackup/mokobackup.xml +++ b/src/packages/com_mokobackup/mokobackup.xml @@ -8,7 +8,7 @@ --> com_mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -19,8 +19,6 @@ Joomla\Component\MokoBackup - script.php - sql/install.mysql.sql @@ -40,45 +38,24 @@ - - provider.php - - - Controller - Engine - Extension - Model - Table - View - - - backup.xml - profile.xml - filter_backups.xml - filter_profiles.xml - - - backups - backup - profiles - profile - - - mysql - updates - - - mokobackup.php + COM_MOKOBACKUP + + COM_MOKOBACKUP_SUBMENU_DASHBOARD + COM_MOKOBACKUP_SUBMENU_BACKUPS + COM_MOKOBACKUP_SUBMENU_PROFILES + + + cli + forms + services + sql + src + tmpl en-GB/com_mokobackup.ini en-GB/com_mokobackup.sys.ini - COM_MOKOBACKUP - - COM_MOKOBACKUP_SUBMENU_BACKUPS - COM_MOKOBACKUP_SUBMENU_PROFILES - diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/src/packages/com_mokobackup/sql/install.mysql.sql index fe7c580..c9c25d3 100644 --- a/src/packages/com_mokobackup/sql/install.mysql.sql +++ b/src/packages/com_mokobackup/sql/install.mysql.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max', `split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part', `backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups', + `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders', `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', @@ -30,8 +31,9 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', - `include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive', + `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive', `notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails', + `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs', `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1, `published` TINYINT(1) NOT NULL DEFAULT 1, @@ -63,8 +65,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( `remote_filename` VARCHAR(512) NOT NULL DEFAULT '', `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive', `base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential', - `manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison', - `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', + `manifest` LONGTEXT DEFAULT NULL COMMENT 'JSON file manifest for differential comparison', + `log` MEDIUMTEXT DEFAULT NULL COMMENT 'Step-by-step backup log', PRIMARY KEY (`id`), KEY `idx_profile` (`profile_id`), KEY `idx_status` (`status`), diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql new file mode 100644 index 0000000..ef33f11 --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql @@ -0,0 +1 @@ +ALTER TABLE `#__mokobackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive'; diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql new file mode 100644 index 0000000..724a3f3 --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql @@ -0,0 +1,7 @@ +-- MokoJoomBackup 01.01.08 +-- Fix: allow NULL defaults for manifest and log columns +ALTER TABLE `#__mokobackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL; +ALTER TABLE `#__mokobackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL; + +-- Add user group notifications column to profiles +ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`; diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql new file mode 100644 index 0000000..32222f4 --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql @@ -0,0 +1,3 @@ +-- MokoJoomBackup 01.01.09 +-- Add archive_name_format column with placeholder support +ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`; diff --git a/src/packages/com_mokobackup/src/Controller/AjaxController.php b/src/packages/com_mokobackup/src/Controller/AjaxController.php index c904f53..7a01ebb 100644 --- a/src/packages/com_mokobackup/src/Controller/AjaxController.php +++ b/src/packages/com_mokobackup/src/Controller/AjaxController.php @@ -68,6 +68,115 @@ class AjaxController extends BaseController $this->sendJson($result); } + /** + * Browse server directories for the folder picker field. + * POST: task=ajax.browseDir&path=/some/path + */ + public function browseDir(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $path = $this->input->getString('path', JPATH_ROOT); + $path = realpath($path) ?: $path; + + if (!is_dir($path)) { + $this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]); + + return; + } + + // Security: only allow browsing within JPATH_ROOT or parent directories + // that could contain a backup folder (e.g., /home/user/backups) + $dirs = []; + $handle = @opendir($path); + + if ($handle) { + while (($entry = readdir($handle)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $fullPath = $path . '/' . $entry; + + if (is_dir($fullPath) && $entry[0] !== '.') { + $dirs[] = [ + 'name' => $entry, + 'path' => $fullPath, + ]; + } + } + + closedir($handle); + } + + usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + + $parent = dirname($path); + + $this->sendJson([ + 'error' => false, + 'current' => $path, + 'parent' => ($parent !== $path) ? $parent : null, + 'dirs' => $dirs, + ]); + } + + /** + * Load and return the log file contents for a backup record. + * POST: task=ajax.viewLog&id=123 + */ + public function viewLog(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $id = $this->input->getInt('id', 0); + + if (!$id) { + $this->sendJson(['error' => true, 'message' => 'Missing record ID']); + + return; + } + + $db = \Joomla\CMS\Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['absolute_path', 'log'])) + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + $this->sendJson(['error' => true, 'message' => 'Record not found']); + + return; + } + + // Try to load log from file alongside the archive + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path); + $logContent = ''; + + if (is_file($logPath)) { + $logContent = file_get_contents($logPath); + } elseif (!empty($record->log)) { + // Fall back to database-stored log + $logContent = $record->log; + } + + $this->sendJson([ + 'error' => false, + 'log' => $logContent ?: '(no log available)', + 'source' => is_file($logPath) ? 'file' : 'database', + ]); + } + /** * Send a JSON response and close the application. */ diff --git a/src/packages/com_mokobackup/src/Controller/BackupsController.php b/src/packages/com_mokobackup/src/Controller/BackupsController.php index 0ad0490..c8a3f15 100644 --- a/src/packages/com_mokobackup/src/Controller/BackupsController.php +++ b/src/packages/com_mokobackup/src/Controller/BackupsController.php @@ -68,17 +68,28 @@ class BackupsController extends AdminController return; } - $app = $this->app; - $app->clearHeaders(); - $app->setHeader('Content-Type', 'application/zip'); - $app->setHeader('Content-Disposition', 'attachment; filename="' . basename($item->archivename) . '"'); - $app->setHeader('Content-Length', (string) filesize($item->absolute_path)); - $app->setHeader('Cache-Control', 'no-cache, must-revalidate'); - $app->sendHeaders(); + // Flush any output buffers to prevent HTML mixing with binary data + while (@ob_end_clean()) { + // clear all buffers + } + + $filename = basename($item->archivename); + $filesize = filesize($item->absolute_path); + + // Detect content type from file extension + $contentType = str_ends_with($filename, '.tar.gz') + ? 'application/gzip' + : 'application/zip'; + + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . $filesize); + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); readfile($item->absolute_path); - $app->close(); + $this->app->close(); } /** diff --git a/src/packages/com_mokobackup/src/Controller/DisplayController.php b/src/packages/com_mokobackup/src/Controller/DisplayController.php index 5e4ec11..5425324 100644 --- a/src/packages/com_mokobackup/src/Controller/DisplayController.php +++ b/src/packages/com_mokobackup/src/Controller/DisplayController.php @@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController; class DisplayController extends BaseController { - protected $default_view = 'backups'; + protected $default_view = 'dashboard'; } diff --git a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php index cafa191..3edbe14 100644 --- a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php +++ b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php @@ -246,7 +246,7 @@ class AkeebaImporter 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', 'remote_keep_local' => 1, - 'include_kickstart' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), + 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'published' => 1, 'ordering' => (int) $akProfile->id, 'created' => $now, diff --git a/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php b/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php new file mode 100644 index 0000000..8edfdfb --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php @@ -0,0 +1,41 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +interface ArchiverInterface +{ + /** + * Open or create the archive at the given path. + */ + public function open(string $path): void; + + /** + * Add a string as a file inside the archive. + */ + public function addFromString(string $localName, string $contents): void; + + /** + * Add a file from disk into the archive. + */ + public function addFile(string $filePath, string $localName): void; + + /** + * Finalize and close the archive. + */ + public function close(): void; + + /** + * Return the file extension for this archive type (e.g. 'zip', 'tar.gz'). + */ + public function getExtension(): string; +} diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index 923c6d4..59254f4 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -13,6 +13,7 @@ namespace Joomla\Component\MokoBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class BackupEngine { @@ -59,18 +60,24 @@ class BackupEngine $excludeFiles = $this->parseNewlineList($profile->exclude_files ?? ''); $excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); - // Determine backup directory - $this->backupDir = JPATH_ROOT . '/' . ($profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'); + // Resolve placeholders in directory and filename + $resolver = new PlaceholderResolver($profile); + + $configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'; + $this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir)); if (!is_dir($this->backupDir)) { mkdir($this->backupDir, 0755, true); } // Create backup record - $now = date('Y-m-d H:i:s'); - $tag = date('Ymd_His'); - $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); - $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + $now = date('Y-m-d H:i:s'); + $tag = $resolver->getTag(); + $archiveFormat = $profile->archive_format ?? 'zip'; + $archiver = $this->createArchiver($archiveFormat); + $archiveExt = $archiver->getExtension(); + $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt; if (empty($description)) { $description = $profile->title . ' — ' . $now; @@ -104,12 +111,8 @@ class BackupEngine $this->log('Backup started: ' . $description); $archivePath = $this->backupDir . '/' . $archiveName; - // Create ZIP archive - $zip = new \ZipArchive(); - - if ($zip->open($archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - throw new \RuntimeException('Cannot create archive: ' . $archivePath); - } + // Create archive + $archiver->open($archivePath); $dbSize = 0; $filesCount = 0; @@ -120,7 +123,7 @@ class BackupEngine $this->log('Starting database dump...'); $dumper = new DatabaseDumper($excludeTables); $sqlDump = $dumper->dump(); - $zip->addFromString('database.sql', $sqlDump); + $archiver->addFromString('database.sql', $sqlDump); $dbSize = strlen($sqlDump); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); @@ -156,7 +159,7 @@ class BackupEngine $fullPath = JPATH_ROOT . '/' . $relativePath; if (is_file($fullPath) && is_readable($fullPath)) { - $zip->addFile($fullPath, $relativePath); + $archiver->addFile($fullPath, $relativePath); } } @@ -169,15 +172,19 @@ class BackupEngine } } - $zip->close(); + $archiver->close(); // Step 1.5: Apply AES-256 encryption (if configured) $encryptionPassword = $profile->encryption_password ?? ''; if (!empty($encryptionPassword)) { - $this->log('Encrypting archive with AES-256...'); - $this->encryptArchive($archivePath, $encryptionPassword); - $this->log('Archive encrypted'); + if ($archiveFormat !== 'zip') { + $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption'); + } else { + $this->log('Encrypting archive with AES-256...'); + $this->encryptArchive($archivePath, $encryptionPassword); + $this->log('Archive encrypted'); + } } // Record archive size and compute checksum (after encryption) @@ -187,21 +194,21 @@ class BackupEngine $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); - // Step 2.5: Wrap with Kickstart restore script (if enabled) - $includeKickstart = (bool) ($profile->include_kickstart ?? false); + // Step 2.5: Wrap with MokoRestore script (if enabled) + $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); - if ($includeKickstart) { - $this->log('Wrapping with Kickstart restore script...'); - $kickstartName = str_replace('.zip', '-kickstart.zip', $archiveName); - $kickstartPath = $this->backupDir . '/' . $kickstartName; - Kickstart::wrap($archivePath, $kickstartPath); + if ($includeMokoRestore) { + $this->log('Wrapping with MokoRestore script...'); + $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); + $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; + MokoRestore::wrap($archivePath, $mokoRestorePath); // Replace the original archive with the wrapped one @unlink($archivePath); - rename($kickstartPath, $archivePath); + rename($mokoRestorePath, $archivePath); $totalSize = filesize($archivePath); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; - $this->log('Kickstart archive created: ' . $sizeHuman); + $this->log('MokoRestore archive created: ' . $sizeHuman); } $remoteFilename = ''; @@ -229,6 +236,11 @@ class BackupEngine } } + // Write log file alongside the archive + $logContent = implode("\n", $this->log); + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath); + @file_put_contents($logPath, $logContent); + // Final record update $update = (object) [ 'id' => $recordId, @@ -242,7 +254,7 @@ class BackupEngine 'remote_filename' => $remoteFilename, 'checksum' => $checksum, 'manifest' => !empty($manifest) ? json_encode($manifest) : '', - 'log' => implode("\n", $this->log), + 'log' => $logContent, ]; $db->updateObject('#__mokobackup_records', $update, 'id'); @@ -250,6 +262,9 @@ class BackupEngine // Send success notification NotificationSender::send($profile, $update, true, implode("\n", $this->log)); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); + return [ 'success' => true, 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', @@ -275,6 +290,9 @@ class BackupEngine // Send failure notification NotificationSender::send($profile, $update, false, implode("\n", $this->log)); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin); + return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId]; } } @@ -354,6 +372,18 @@ class BackupEngine return true; } + /** + * Create the appropriate archiver based on the archive format. + */ + private function createArchiver(string $format): ArchiverInterface + { + return match ($format) { + 'zip' => new ZipArchiver(), + 'tar.gz' => new TarGzArchiver(), + default => new ZipArchiver(), + }; + } + /** * Create the appropriate remote uploader based on the storage type. */ @@ -445,6 +475,41 @@ class BackupEngine )); } + /** + * Dispatch the onMokoBackupAfterRun event so plugins (actionlog, etc.) can react. + */ + private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void + { + try { + $app = Factory::getApplication(); + + $event = new Event('onMokoBackupAfterRun', [ + 'success' => $success, + 'record_id' => $recordId, + 'description' => $description, + 'profile_id' => $profileId, + 'origin' => $origin, + ]); + + $app->getDispatcher()->dispatch('onMokoBackupAfterRun', $event); + } catch (\Throwable $e) { + // Never let a listener failure break the backup result + } + } + + /** + * Resolve a backup directory path. Absolute paths are used as-is, + * relative paths are resolved from JPATH_ROOT. + */ + private function resolveBackupDir(string $dir): string + { + if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) { + return rtrim($dir, '/\\'); + } + + return JPATH_ROOT . '/' . $dir; + } + private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php index 3c81269..05661c5 100644 --- a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php +++ b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php @@ -16,15 +16,33 @@ use Joomla\CMS\Factory; class DatabaseDumper { - private array $excludeTables; + /** @var array Tables to exclude entirely (both structure and data) */ + private array $excludeBoth = []; + + /** @var array Tables to exclude data only (structure is kept) */ + private array $excludeDataOnly = []; + + /** @var array Tables to exclude structure only (data is kept — unusual) */ + private array $excludeStructureOnly = []; + private int $tablesCount = 0; /** - * @param array $excludeTables Table names to exclude (with #__ prefix) + * @param array $excludeTables Table names to exclude (with #__ prefix). + * Supports suffixes: :data-only, :structure-only. + * No suffix = exclude both (backward compatible). */ public function __construct(array $excludeTables = []) { - $this->excludeTables = $excludeTables; + foreach ($excludeTables as $entry) { + if (str_ends_with($entry, ':data-only')) { + $this->excludeDataOnly[] = substr($entry, 0, -10); + } elseif (str_ends_with($entry, ':structure-only')) { + $this->excludeStructureOnly[] = substr($entry, 0, -15); + } else { + $this->excludeBoth[] = $entry; + } + } } /** @@ -62,29 +80,49 @@ class DatabaseDumper // Check if excluded $abstractName = '#__' . substr($table, strlen($prefix)); - if ($this->isExcluded($abstractName, $table)) { + if ($this->isExcludedBoth($abstractName, $table)) { continue; } + $skipData = $this->isExcludedDataOnly($abstractName, $table); + $skipStructure = $this->isExcludedStructureOnly($abstractName, $table); + $this->tablesCount++; - // Get CREATE TABLE statement - $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); - $createRow = $db->loadRow(); + $output[] = '-- --------------------------------------------------------'; + $output[] = '-- Table: ' . $table; - if (!$createRow || empty($createRow[1])) { - continue; + if ($skipData) { + $output[] = '-- (data excluded)'; + } + + if ($skipStructure) { + $output[] = '-- (structure excluded)'; } $output[] = '-- --------------------------------------------------------'; - $output[] = '-- Table: ' . $table; - $output[] = '-- --------------------------------------------------------'; - $output[] = ''; - $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; - $output[] = $createRow[1] . ';'; $output[] = ''; - // Dump data in chunks + // Get CREATE TABLE statement (unless structure is excluded) + if (!$skipStructure) { + $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); + $createRow = $db->loadRow(); + + if (!$createRow || empty($createRow[1])) { + continue; + } + + $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; + $output[] = $createRow[1] . ';'; + $output[] = ''; + } + + // Dump data (unless data is excluded) + if ($skipData) { + $output[] = ''; + continue; + } + $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); $rowCount = (int) $db->loadResult(); @@ -135,11 +173,39 @@ class DatabaseDumper } /** - * Check if a table is excluded. + * Check if a table is fully excluded (both data and structure). */ - private function isExcluded(string $abstractName, string $realName): bool + private function isExcludedBoth(string $abstractName, string $realName): bool { - foreach ($this->excludeTables as $pattern) { + foreach ($this->excludeBoth as $pattern) { + if ($pattern === $abstractName || $pattern === $realName) { + return true; + } + } + + return false; + } + + /** + * Check if a table's data is excluded (structure only). + */ + private function isExcludedDataOnly(string $abstractName, string $realName): bool + { + foreach ($this->excludeDataOnly as $pattern) { + if ($pattern === $abstractName || $pattern === $realName) { + return true; + } + } + + return false; + } + + /** + * Check if a table's structure is excluded (data only). + */ + private function isExcludedStructureOnly(string $abstractName, string $realName): bool + { + foreach ($this->excludeStructureOnly as $pattern) { if ($pattern === $abstractName || $pattern === $realName) { return true; } diff --git a/src/packages/com_mokobackup/src/Engine/Kickstart.php b/src/packages/com_mokobackup/src/Engine/MokoRestore.php similarity index 98% rename from src/packages/com_mokobackup/src/Engine/Kickstart.php rename to src/packages/com_mokobackup/src/Engine/MokoRestore.php index e230c10..38c2c0a 100644 --- a/src/packages/com_mokobackup/src/Engine/Kickstart.php +++ b/src/packages/com_mokobackup/src/Engine/MokoRestore.php @@ -9,7 +9,7 @@ * * Standalone restore script generator. * - * When "Include Kickstart" is enabled on a profile, the backup archive + * When "Include MokoRestore" is enabled on a profile, the backup archive * is wrapped: * * outer.zip @@ -17,14 +17,14 @@ * └── site-backup.zip ← The actual site backup * * Upload outer.zip to a blank server, extract, open restore.php in a - * browser, and it handles everything — just like Akeeba Kickstart. + * browser, and it handles everything — self-contained site restoration. */ namespace Joomla\Component\MokoBackup\Administrator\Engine; defined('_JEXEC') or die; -class Kickstart +class MokoRestore { /** * Wrap a backup archive with the standalone restore script. @@ -39,7 +39,7 @@ class Kickstart $zip = new \ZipArchive(); if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - throw new \RuntimeException('Cannot create kickstart archive: ' . $outputPath); + throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath); } // Add the standalone restore script @@ -68,7 +68,7 @@ class Kickstart return <<<'RESTORE_PHP' notify_email ?? ''); + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; - if (empty($notifyEmail)) { + // Resolve user group members to email addresses + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { return false; } @@ -54,9 +58,10 @@ class NotificationSender $siteName = $config->get('sitename', 'Joomla Site'); $siteUrl = Uri::root(); - // Parse recipient list (comma-separated) + // Parse recipient list (comma-separated) + user group emails $recipients = array_map('trim', explode(',', $notifyEmail)); - $recipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); if (empty($recipients)) { return false; @@ -133,4 +138,41 @@ class NotificationSender return false; } } + + /** + * Resolve user group IDs to email addresses of group members. + * + * @param string|array $groups Comma-separated group IDs or array + * + * @return array Email addresses + */ + private static function resolveUserGroupEmails(string|array $groups): array + { + if (empty($groups)) { + return []; + } + + if (\is_string($groups)) { + $groups = array_filter(array_map('intval', explode(',', $groups))); + } + + if (empty($groups)) { + return []; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('u.email')) + ->from($db->quoteName('#__users', 'u')) + ->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id') + ->where($db->quoteName('u.block') . ' = 0') + ->whereIn($db->quoteName('ugm.group_id'), $groups); + $db->setQuery($query); + + return $db->loadColumn() ?: []; + } catch (\Throwable $e) { + return []; + } + } } diff --git a/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php b/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php new file mode 100644 index 0000000..712b219 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php @@ -0,0 +1,122 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Resolves placeholders like [host], [date], [profile_name] in backup + * directory paths and archive filename formats. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class PlaceholderResolver +{ + /** + * Supported placeholders and their descriptions (for documentation). + */ + public const PLACEHOLDERS = [ + '[host]' => 'Server hostname', + '[date]' => 'Date as Ymd (e.g. 20260604)', + '[time]' => 'Time as His (e.g. 143025)', + '[datetime]' => 'Date and time as Ymd_His', + '[year]' => 'Four-digit year', + '[month]' => 'Two-digit month', + '[day]' => 'Two-digit day', + '[hour]' => 'Two-digit hour (24h)', + '[minute]' => 'Two-digit minute', + '[second]' => 'Two-digit second', + '[profile_id]' => 'Backup profile ID', + '[profile_name]' => 'Profile title (sanitized)', + '[site_name]' => 'Joomla site name (sanitized)', + '[type]' => 'Backup type (full, database, files, differential)', + '[random]' => 'Random 6-character hex string', + ]; + + private array $replacements; + + /** + * @param object $profile The backup profile object + */ + public function __construct(object $profile) + { + $now = new \DateTimeImmutable('now'); + $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); + + $siteName = ''; + + try { + $siteName = Factory::getApplication()->get('sitename', ''); + } catch (\Throwable $e) { + // Fallback: not critical + } + + $this->replacements = [ + '[host]' => $hostname, + '[date]' => $now->format('Ymd'), + '[time]' => $now->format('His'), + '[datetime]' => $now->format('Ymd_His'), + '[year]' => $now->format('Y'), + '[month]' => $now->format('m'), + '[day]' => $now->format('d'), + '[hour]' => $now->format('H'), + '[minute]' => $now->format('i'), + '[second]' => $now->format('s'), + '[profile_id]' => (string) ($profile->id ?? '0'), + '[profile_name]' => $this->sanitize($profile->title ?? 'default'), + '[site_name]' => $this->sanitize($siteName ?: 'joomla'), + '[type]' => $profile->backup_type ?? 'full', + '[random]' => bin2hex(random_bytes(3)), + ]; + } + + /** + * Replace all placeholders in a string. + * + * @param string $template String containing [placeholder] tokens + * + * @return string Resolved string + */ + public function resolve(string $template): string + { + return str_replace( + array_keys($this->replacements), + array_values($this->replacements), + $template + ); + } + + /** + * Get the raw hostname value (for backward compatibility). + */ + public function getHostname(): string + { + return $this->replacements['[host]']; + } + + /** + * Get the datetime tag value (for backward compatibility). + */ + public function getTag(): string + { + return $this->replacements['[datetime]']; + } + + /** + * Sanitize a string for use in filenames/paths. + * Keeps alphanumerics, dots, hyphens, underscores. Replaces spaces with hyphens. + */ + private function sanitize(string $value): string + { + $value = str_replace(' ', '-', trim($value)); + + return preg_replace('/[^a-zA-Z0-9._-]/', '', $value); + } +} diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php index eb33467..7099957 100644 --- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php +++ b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php @@ -89,12 +89,15 @@ class RestoreEngine // Step 1: Extract archive to staging $this->log('Extracting archive: ' . basename($archivePath)); - // Detect format: JPA or ZIP + // Detect format: JPA, tar.gz, or ZIP if (JpaUnarchiver::isJpaFile($archivePath)) { $this->log('Detected JPA format (Akeeba Backup archive)'); $jpa = new JpaUnarchiver($archivePath, $this->stagingDir); $count = $jpa->extract(); $this->log('Extracted ' . $count . ' files from JPA'); + } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { + $this->log('Detected tar.gz format'); + $this->extractTarGz($archivePath); } else { $this->extractArchive($archivePath, $password); } @@ -200,6 +203,16 @@ class RestoreEngine $zip->close(); } + /** + * Extract a tar.gz archive to the staging directory. + */ + private function extractTarGz(string $archivePath): void + { + $phar = new \PharData($archivePath); + $phar->extractTo($this->stagingDir, null, true); + $this->log('Extracted tar.gz archive'); + } + /** * Recursively delete a directory and all its contents. */ diff --git a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php index 02ef27a..7a7559a 100644 --- a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php @@ -57,20 +57,21 @@ class SteppedBackupEngine $session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); $session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'; $session->remoteStorage = $profile->remote_storage ?? 'none'; - $session->includeKickstart = (bool) ($profile->include_kickstart ?? false); + $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); - // Build archive path - $backupDir = JPATH_ROOT . '/' . $session->backupDir; + // Resolve placeholders in directory and filename + $resolver = new PlaceholderResolver($profile); + $backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir)); if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } - $now = date('Y-m-d H:i:s'); - $tag = date('Ymd_His'); - $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); - $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + $now = date('Y-m-d H:i:s'); + $tag = $resolver->getTag(); + $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $archiveName = $resolver->resolve($nameFormat) . '.zip'; $session->archivePath = $backupDir . '/' . $archiveName; $session->archiveName = $archiveName; @@ -288,7 +289,7 @@ class SteppedBackupEngine } /** - * Finalize phase: add database.sql to ZIP, apply kickstart wrapper. + * Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper. */ private function stepFinalize(SteppedSession $session): void { @@ -314,15 +315,15 @@ class SteppedBackupEngine $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; - // Kickstart wrapper - if ($session->includeKickstart) { - $session->log('Wrapping with Kickstart restore script...'); - $kickstartPath = $session->archivePath . '.kickstart.zip'; - Kickstart::wrap($session->archivePath, $kickstartPath); + // MokoRestore wrapper + if ($session->includeMokoRestore) { + $session->log('Wrapping with MokoRestore script...'); + $mokoRestorePath = $session->archivePath . '.mokorestore.zip'; + MokoRestore::wrap($session->archivePath, $mokoRestorePath); @unlink($session->archivePath); - rename($kickstartPath, $session->archivePath); + rename($mokoRestorePath, $session->archivePath); $totalSize = filesize($session->archivePath); - $session->log('Kickstart archive created'); + $session->log('MokoRestore archive created'); } // Update record @@ -408,12 +409,18 @@ class SteppedBackupEngine */ private function completeRecord(SteppedSession $session): void { - $db = Factory::getDbo(); + $db = Factory::getDbo(); + $logContent = implode("\n", $session->log); + + // Write log file alongside the archive + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath); + @file_put_contents($logPath, $logContent); + $update = (object) [ 'id' => $session->recordId, 'status' => 'complete', 'backupend' => date('Y-m-d H:i:s'), - 'log' => implode("\n", $session->log), + 'log' => $logContent, ]; $db->updateObject('#__mokobackup_records', $update, 'id'); @@ -536,6 +543,19 @@ class SteppedBackupEngine return $tables; } + /** + * Resolve a backup directory path. Absolute paths are used as-is, + * relative paths are resolved from JPATH_ROOT. + */ + private function resolveBackupDir(string $dir): string + { + if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) { + return rtrim($dir, '/\\'); + } + + return JPATH_ROOT . '/' . $dir; + } + private function parseNewlineList(string $text): array { if (empty($text)) { diff --git a/src/packages/com_mokobackup/src/Engine/SteppedSession.php b/src/packages/com_mokobackup/src/Engine/SteppedSession.php index b4f3002..76f000d 100644 --- a/src/packages/com_mokobackup/src/Engine/SteppedSession.php +++ b/src/packages/com_mokobackup/src/Engine/SteppedSession.php @@ -51,7 +51,7 @@ class SteppedSession public array $excludeFiles = []; public array $excludeTables = []; public string $remoteStorage = 'none'; - public bool $includeKickstart = false; + public bool $includeMokoRestore = false; public bool $remoteKeepLocal = true; // Progress diff --git a/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php new file mode 100644 index 0000000..fdce0ce --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php @@ -0,0 +1,63 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class TarGzArchiver implements ArchiverInterface +{ + private \PharData $tar; + private string $tarPath; + + public function open(string $path): void + { + // PharData creates .tar first, then we compress to .tar.gz + // Strip .gz to get the .tar path for initial creation + $this->tarPath = preg_replace('/\.gz$/', '', $path); + + // Remove existing files to avoid "already exists" errors + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + + if (is_file($path)) { + @unlink($path); + } + + $this->tar = new \PharData($this->tarPath); + } + + public function addFromString(string $localName, string $contents): void + { + $this->tar->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->tar->addFile($filePath, $localName); + } + + public function close(): void + { + // Compress the .tar to .tar.gz + $this->tar->compress(\Phar::GZ); + + // Remove the uncompressed .tar + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + } + + public function getExtension(): string + { + return 'tar.gz'; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/ZipArchiver.php b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php new file mode 100644 index 0000000..e161035 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php @@ -0,0 +1,47 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class ZipArchiver implements ArchiverInterface +{ + private \ZipArchive $zip; + + public function open(string $path): void + { + $this->zip = new \ZipArchive(); + + if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create ZIP archive: ' . $path); + } + } + + public function addFromString(string $localName, string $contents): void + { + $this->zip->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->zip->addFile($filePath, $localName); + } + + public function close(): void + { + $this->zip->close(); + } + + public function getExtension(): string + { + return 'zip'; + } +} diff --git a/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php new file mode 100644 index 0000000..937202d --- /dev/null +++ b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php @@ -0,0 +1,147 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class DatabaseTablesField extends FormField +{ + protected $type = 'DatabaseTables'; + + protected function getInput(): string + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + // Parse current exclusions (newline-separated, with optional :data-only suffix) + $excludeData = []; + $excludeStructure = []; + + if (!empty($this->value)) { + $lines = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))); + + foreach ($lines as $line) { + // Normalize table name to real prefix for comparison + if (str_ends_with($line, ':data-only')) { + $tableName = str_replace('#__', $prefix, substr($line, 0, -10)); + $excludeData[$tableName] = true; + } elseif (str_ends_with($line, ':structure-only')) { + $tableName = str_replace('#__', $prefix, substr($line, 0, -15)); + $excludeStructure[$tableName] = true; + } else { + // No suffix = exclude both (backward compatible) + $tableName = str_replace('#__', $prefix, $line); + $excludeData[$tableName] = true; + $excludeStructure[$tableName] = true; + } + } + } + + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + + $html = '
'; + $html .= ''; + $html .= '
' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '
'; + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($tables as $table) { + $dataChecked = isset($excludeData[$table]) ? ' checked' : ''; + $structureChecked = isset($excludeStructure[$table]) ? ' checked' : ''; + + // Convert to #__ notation for storage + $storeValue = $table; + + if (str_starts_with($table, $prefix)) { + $storeValue = '#__' . substr($table, \strlen($prefix)); + } + + $safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8'); + $safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8'); + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_DATA') . '' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE') . '' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '
' . $safeTable . '
'; + + // Script to sync checkboxes to hidden field + $html .= << +SCRIPT; + + return $html; + } +} diff --git a/src/packages/com_mokobackup/src/Field/ExcludeListField.php b/src/packages/com_mokobackup/src/Field/ExcludeListField.php new file mode 100644 index 0000000..483e68c --- /dev/null +++ b/src/packages/com_mokobackup/src/Field/ExcludeListField.php @@ -0,0 +1,120 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class ExcludeListField extends FormField +{ + protected $type = 'ExcludeList'; + + protected function getInput(): string + { + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8'); + + // Parse current values (newline-separated) + $items = []; + + if (!empty($this->value)) { + $items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))))); + } + + $html = '
'; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($items as $item) { + $safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8'); + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
'; + $html .= ''; + $html .= '
'; + + $html .= << +SCRIPT; + + return $html; + } +} diff --git a/src/packages/com_mokobackup/src/Field/FolderPickerField.php b/src/packages/com_mokobackup/src/Field/FolderPickerField.php new file mode 100644 index 0000000..447725d --- /dev/null +++ b/src/packages/com_mokobackup/src/Field/FolderPickerField.php @@ -0,0 +1,170 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class FolderPickerField extends FormField +{ + protected $type = 'FolderPicker'; + + protected function getInput(): string + { + $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8'); + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $jRoot = JPATH_ROOT; + + // Resolve to absolute for display + $rawValue = $this->value ?: $this->default; + + if ($rawValue && $rawValue[0] !== '/') { + $absPath = $jRoot . '/' . $rawValue; + } else { + $absPath = $rawValue; + } + + $exists = is_dir($absPath); + $statusClass = $exists ? 'text-success' : 'text-danger'; + $statusIcon = $exists ? 'icon-publish' : 'icon-unpublish'; + $statusText = $exists + ? Text::_('COM_MOKOBACKUP_FOLDER_EXISTS') + : Text::_('COM_MOKOBACKUP_FOLDER_NOT_FOUND'); + $absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8'); + + return << + + + +
+ + + {$statusText}: {$absPathSafe} + +
+ + +HTML; + } +} diff --git a/src/packages/com_mokobackup/src/Model/DashboardModel.php b/src/packages/com_mokobackup/src/Model/DashboardModel.php new file mode 100644 index 0000000..48fcb79 --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/DashboardModel.php @@ -0,0 +1,207 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class DashboardModel extends BaseDatabaseModel +{ + /** + * Get the most recent completed backup record. + * + * @return object|null + */ + public function getLastBackup(): ?object + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('r.*, p.title AS profile_title') + ->from($db->quoteName('#__mokobackup_records', 'r')) + ->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id') + ->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) + ->order($db->quoteName('r.backupend') . ' DESC'); + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: null; + } + + /** + * Query com_scheduler for the next scheduled MokoBackup task. + * + * @return object|null Object with next_execution and title, or null + */ + public function getNextScheduled(): ?object + { + $db = $this->getDatabase(); + + try { + $query = $db->getQuery(true) + ->select($db->quoteName(['t.next_execution', 't.title'])) + ->from($db->quoteName('#__scheduler_tasks', 't')) + ->where($db->quoteName('t.type') . ' = ' . $db->quote('mokobackup.run_profile')) + ->where($db->quoteName('t.state') . ' = 1') + ->order($db->quoteName('t.next_execution') . ' ASC'); + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get backup statistics. + * + * @return object Object with total_count, total_size, fail_count_7d + */ + public function getStats(): object + { + $db = $this->getDatabase(); + + // Total completed backups and storage + $query = $db->getQuery(true) + ->select('COUNT(*) AS total_count') + ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $stats = $db->loadObject(); + + // Failures in last 7 days + $cutoff = date('Y-m-d H:i:s', strtotime('-7 days')); + $query = $db->getQuery(true) + ->select('COUNT(*) AS fail_count') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('fail')) + ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff)); + $db->setQuery($query); + $stats->fail_count_7d = (int) $db->loadResult(); + + return $stats; + } + + /** + * Check system health for backup readiness. + * + * @return array Array of check results [{label, status, detail}] + */ + public function getSystemHealth(): array + { + $checks = []; + + // PHP version + $checks[] = (object) [ + 'label' => 'PHP Version', + 'status' => version_compare(PHP_VERSION, '8.1.0', '>='), + 'detail' => PHP_VERSION, + ]; + + // ZipArchive extension + $checks[] = (object) [ + 'label' => 'ZipArchive', + 'status' => extension_loaded('zip'), + 'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded', + ]; + + // AES-256 encryption support + $aesSupport = defined('ZipArchive::EM_AES_256'); + $checks[] = (object) [ + 'label' => 'AES-256 Encryption', + 'status' => $aesSupport, + 'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+', + ]; + + // Backup directory writable — check the default path + $defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups'; + $backupDir = $defaultDir; + + // If profiles use a custom directory, check that instead + $db2 = $this->getDatabase(); + $qDir = $db2->getQuery(true) + ->select($db2->quoteName('backup_dir')) + ->from($db2->quoteName('#__mokobackup_profiles')) + ->where($db2->quoteName('published') . ' = 1') + ->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote('')) + ->where($db2->quoteName('backup_dir') . ' IS NOT NULL'); + $db2->setQuery($qDir, 0, 1); + $profileDir = $db2->loadResult(); + + if ($profileDir) { + // Absolute paths used as-is, relative resolved from JPATH_ROOT + if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) { + $backupDir = rtrim($profileDir, '/\\'); + } else { + $backupDir = JPATH_ROOT . '/' . $profileDir; + } + } + + $writable = is_dir($backupDir) && is_writable($backupDir); + $checks[] = (object) [ + 'label' => 'Backup Directory', + 'status' => $writable, + 'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir, + ]; + + // Disk space + $freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT); + $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0; + $checks[] = (object) [ + 'label' => 'Free Disk Space', + 'status' => $freeGB >= 1.0, + 'detail' => $freeGB . ' GB free', + ]; + + return $checks; + } + + /** + * Check if any profiles use the default (web-root) backup directory. + * + * @return bool + */ + public function isUsingDefaultBackupDir(): bool + { + $db = $this->getDatabase(); + $default = 'administrator/components/com_mokobackup/backups'; + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokobackup_profiles')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default) + . ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('') + . ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)'); + $db->setQuery($query); + + return (int) $db->loadResult() > 0; + } + + /** + * Get published backup profiles for the quick-action selector. + * + * @return array + */ + public function getProfiles(): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'title', 'backup_type'])) + ->from($db->quoteName('#__mokobackup_profiles')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php index 1dde363..f1a224c 100644 --- a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php +++ b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php @@ -15,6 +15,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; use Joomla\CMS\Toolbar\ToolbarHelper; class HtmlView extends BaseHtmlView @@ -44,11 +45,62 @@ class HtmlView extends BaseHtmlView $db->setQuery($query); $this->profiles = $db->loadObjectList() ?: []; + $this->checkUpdateSite(); $this->addToolbar(); parent::display($tpl); } + /** + * Show an info notice linking to the update site record so the user + * can configure their download key for automatic updates. + */ + protected function checkUpdateSite(): void + { + try { + $db = Factory::getDbo(); + + // Find the update site ID linked to pkg_mokobackup + $query = $db->getQuery(true) + ->select($db->quoteName('us.update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join( + 'INNER', + $db->quoteName('#__update_sites_extensions', 'use') + . ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id') + ) + ->join( + 'INNER', + $db->quoteName('#__extensions', 'e') + . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id') + ) + ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup')) + ->where($db->quoteName('e.type') . ' = ' . $db->quote('package')) + ->setLimit(1); + + $db->setQuery($query); + $updateSiteId = (int) $db->loadResult(); + + if ($updateSiteId > 0) { + $editUrl = Route::_( + 'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId + ); + + Factory::getApplication()->enqueueMessage( + Text::sprintf('COM_MOKOBACKUP_UPDATE_SITE_NOTICE', $editUrl), + 'info' + ); + } else { + Factory::getApplication()->enqueueMessage( + Text::_('COM_MOKOBACKUP_UPDATE_SITE_MISSING'), + 'warning' + ); + } + } catch (\Throwable $e) { + // Non-critical — silently ignore + } + } + protected function addToolbar(): void { ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database'); diff --git a/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php b/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..ac24790 --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php @@ -0,0 +1,50 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\View\Dashboard; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public ?object $lastBackup = null; + public ?object $nextScheduled = null; + public object $stats; + public array $systemHealth = []; + public array $profiles = []; + public bool $defaultDirWarning = false; + + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoBackup\Administrator\Model\DashboardModel $model */ + $model = $this->getModel(); + + $this->lastBackup = $model->getLastBackup(); + $this->nextScheduled = $model->getNextScheduled(); + $this->stats = $model->getStats(); + $this->systemHealth = $model->getSystemHealth(); + $this->profiles = $model->getProfiles(); + $this->defaultDirWarning = $model->isUsingDefaultBackupDir(); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOBACKUP_DASHBOARD_TITLE'), 'archive'); + ToolbarHelper::preferences('com_mokobackup'); + } +} diff --git a/src/packages/com_mokobackup/tmpl/backup/default.php b/src/packages/com_mokobackup/tmpl/backup/default.php index 22976e9..2bf829b 100644 --- a/src/packages/com_mokobackup/tmpl/backup/default.php +++ b/src/packages/com_mokobackup/tmpl/backup/default.php @@ -12,7 +12,11 @@ defined('_JEXEC') or die; use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; +$ajaxToken = Session::getFormToken(); +$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false); ?>
@@ -22,7 +26,17 @@ use Joomla\CMS\Language\Text; - escape($this->item->status); ?> + + item->status) { + 'complete' => 'badge bg-success', + 'running' => 'badge bg-info', + 'fail' => 'badge bg-danger', + default => 'badge bg-secondary', + }; + ?> + escape($this->item->status); ?> + @@ -34,7 +48,12 @@ use Joomla\CMS\Language\Text; - item->total_size); ?> + + item->total_size); ?> + item->db_size > 0) : ?> + (: item->db_size); ?>) + + @@ -46,7 +65,11 @@ use Joomla\CMS\Language\Text; - escape($this->item->archivename); ?> + escape($this->item->archivename); ?> + + + + escape($this->item->absolute_path); ?> @@ -56,7 +79,47 @@ use Joomla\CMS\Language\Text; item->tables_count; ?> + item->checksum)) : ?> + + + escape($this->item->checksum); ?> + + + item->remote_filename)) : ?> + + + escape($this->item->remote_filename); ?> + + + + +

+
+
Loading...
+
+ + diff --git a/src/packages/com_mokobackup/tmpl/backups/default.php b/src/packages/com_mokobackup/tmpl/backups/default.php index 590f519..cba340d 100644 --- a/src/packages/com_mokobackup/tmpl/backups/default.php +++ b/src/packages/com_mokobackup/tmpl/backups/default.php @@ -99,7 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction')); id); ?> - escape($item->description); ?> + + escape($item->description); ?> + + checksum)) : ?> +
: checksum, 0, 16); ?>... + escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?> @@ -130,13 +135,18 @@ $listDirn = $this->escape($this->state->get('list.direction')); backupstart, Text::_('DATE_FORMAT_LC4')); ?> - + status === 'complete' && $item->filesexist) : ?> + id; ?> @@ -274,5 +284,58 @@ $listDirn = $this->escape($this->state->get('list.direction')); // Expose for toolbar button window.mokobackupStart = startSteppedBackup; + + // View Log modal handler + document.addEventListener('click', function(e) { + var btn = e.target.closest('.mb-view-log'); + if (!btn) return; + e.preventDefault(); + var recordId = btn.getAttribute('data-id'); + var modal = document.getElementById('mb-log-modal'); + var body = document.getElementById('mb-log-body'); + body.textContent = 'Loading...'; + modal.style.display = 'block'; + + var form = new URLSearchParams(); + form.append('task', 'ajax.viewLog'); + form.append('id', recordId); + form.append(TOKEN_NAME, '1'); + + fetch(AJAX_URL, { + method: 'POST', + body: form, + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.error) { + body.textContent = data.message || 'Error loading log'; + } else { + body.textContent = data.log; + } + }) + .catch(function(err) { + body.textContent = 'Error: ' + err.message; + }); + }); + + document.addEventListener('click', function(e) { + if (e.target.id === 'mb-log-modal' || e.target.classList.contains('mb-log-close')) { + document.getElementById('mb-log-modal').style.display = 'none'; + } + }); })(); + + + diff --git a/src/packages/com_mokobackup/tmpl/dashboard/default.php b/src/packages/com_mokobackup/tmpl/dashboard/default.php new file mode 100644 index 0000000..5f33f87 --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/dashboard/default.php @@ -0,0 +1,278 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; + +$ajaxToken = Session::getFormToken(); +$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false); +?> +defaultDirWarning) : ?> + + + +
+ +
+
+
+ +
+ lastBackup) : ?> +

+ lastBackup->backupend, Text::_('DATE_FORMAT_LC4')); ?> +

+ + escape($this->lastBackup->profile_title); ?> + — + lastBackup->total_size); ?> + + +

+ +
+
+
+ +
+
+
+ +
+ nextScheduled) : ?> +

+ nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?> +

+ escape($this->nextScheduled->title); ?> + +

+ +
+
+
+ +
+
+
+ +
+

stats->total_count; ?>

+
+
+
+ +
+
+
+ +
+

+ stats->total_size); ?> +

+ stats->fail_count_7d > 0) : ?> + + stats->fail_count_7d); ?> + + +
+
+
+
+ + +
+
+
+
+
+
+
+ profiles)) : ?> +
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+
+ + + systemHealth as $check) : ?> + + + + + + + +
+ status) : ?> + + + + + escape($check->label); ?>escape($check->detail); ?>
+
+
+
+
+ + + + + diff --git a/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini new file mode 100644 index 0000000..6997740 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Actionlog Plugin language file (en-GB) +PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup" +PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs." +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})" +PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})" diff --git a/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini new file mode 100644 index 0000000..3e1c655 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Actionlog Plugin system language file (en-GB) +PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup" +PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs." diff --git a/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini new file mode 100644 index 0000000..27cf1d6 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Actionlog Plugin language file (en-US) +PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup" +PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs." +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})" +PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})" +PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})" diff --git a/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini new file mode 100644 index 0000000..1737124 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Actionlog Plugin system language file (en-US) +PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup" +PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs." diff --git a/src/packages/plg_actionlog_mokobackup/mokobackup.php b/src/packages/plg_actionlog_mokobackup/mokobackup.php new file mode 100644 index 0000000..2a4226a --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/mokobackup.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_actionlog_mokobackup/mokobackup.xml b/src/packages/plg_actionlog_mokobackup/mokobackup.xml new file mode 100644 index 0000000..343dc1a --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/mokobackup.xml @@ -0,0 +1,32 @@ + + + + plg_actionlog_mokobackup + 01.01.07-dev + 2026-06-04 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\Actionlog\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_actionlog_mokobackup.ini + language/en-GB/plg_actionlog_mokobackup.sys.ini + + diff --git a/src/packages/plg_actionlog_mokobackup/services/provider.php b/src/packages/plg_actionlog_mokobackup/services/provider.php new file mode 100644 index 0000000..b13a445 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/services/provider.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 + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\Actionlog\MokoBackup\Extension\MokoBackupActionlog; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackupActionlog( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('actionlog', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php b/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php new file mode 100644 index 0000000..2cb97e7 --- /dev/null +++ b/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php @@ -0,0 +1,174 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Actionlog\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Event\Model; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\Actionlogs\Administrator\Helper\ActionlogsHelper; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; + +final class MokoBackupActionlog extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onContentAfterSave' => 'onContentAfterSave', + 'onContentAfterDelete' => 'onContentAfterDelete', + 'onMokoBackupAfterRun' => 'onMokoBackupAfterRun', + ]; + } + + /** + * Log when a backup profile is saved (created or updated). + */ + public function onContentAfterSave(Event $event): void + { + [$context, $table, $isNew] = array_values($event->getArguments()); + + if ($context !== 'com_mokobackup.profile') { + return; + } + + $messageKey = $isNew + ? 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED' + : 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED'; + + $this->addLog( + [ + $messageKey, + 'id' => $table->id, + 'title' => $table->title, + 'userid' => $this->getCurrentUserId(), + 'username' => $this->getCurrentUserName(), + ], + $messageKey, + 'com_mokobackup.profile', + $this->getCurrentUserId() + ); + } + + /** + * Log when a backup profile or record is deleted. + */ + public function onContentAfterDelete(Event $event): void + { + [$context, $table] = array_values($event->getArguments()); + + if ($context === 'com_mokobackup.profile') { + $this->addLog( + [ + 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED', + 'id' => $table->id, + 'title' => $table->title ?? '', + 'userid' => $this->getCurrentUserId(), + 'username' => $this->getCurrentUserName(), + ], + 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED', + 'com_mokobackup.profile', + $this->getCurrentUserId() + ); + } elseif ($context === 'com_mokobackup.backup') { + $this->addLog( + [ + 'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED', + 'id' => $table->id, + 'title' => $table->description ?? 'Backup #' . $table->id, + 'userid' => $this->getCurrentUserId(), + 'username' => $this->getCurrentUserName(), + ], + 'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED', + 'com_mokobackup.backup', + $this->getCurrentUserId() + ); + } + } + + /** + * Log when a backup completes or fails. + * This event should be dispatched from BackupEngine. + */ + public function onMokoBackupAfterRun(Event $event): void + { + $args = $event->getArguments(); + + $success = $args['success'] ?? false; + $recordId = $args['record_id'] ?? 0; + $description = $args['description'] ?? ''; + $profileId = $args['profile_id'] ?? 0; + $origin = $args['origin'] ?? 'backend'; + + $messageKey = $success + ? 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE' + : 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED'; + + $this->addLog( + [ + $messageKey, + 'id' => $recordId, + 'title' => $description ?: 'Backup #' . $recordId, + 'profile_id' => $profileId, + 'origin' => $origin, + 'userid' => $this->getCurrentUserId(), + 'username' => $this->getCurrentUserName(), + ], + $messageKey, + 'com_mokobackup.backup', + $this->getCurrentUserId() + ); + } + + /** + * Write an action log entry. + */ + private function addLog(array $message, string $messageLanguageKey, string $context, int $userId): void + { + $params = [ + 'message_language_key' => $messageLanguageKey, + 'message' => json_encode($message), + 'date' => date('Y-m-d H:i:s'), + 'extension' => 'com_mokobackup', + 'user_id' => $userId, + 'ip_address' => ActionlogsHelper::getIp(), + 'item_id' => $message['id'] ?? 0, + ]; + + try { + $db = Factory::getDbo(); + $db->insertObject('#__action_logs', (object) $params); + } catch (\Throwable $e) { + // Non-critical — don't break the operation + } + } + + private function getCurrentUserId(): int + { + try { + return (int) Factory::getApplication()->getIdentity()->id; + } catch (\Throwable $e) { + return 0; + } + } + + private function getCurrentUserName(): string + { + try { + return Factory::getApplication()->getIdentity()->username ?: 'system'; + } catch (\Throwable $e) { + return 'system'; + } + } +} diff --git a/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini new file mode 100644 index 0000000..4b87bca --- /dev/null +++ b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Console Plugin language file (en-GB) +PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup" +PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup." diff --git a/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini new file mode 100644 index 0000000..02fb8d8 --- /dev/null +++ b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Console Plugin system language file (en-GB) +PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup" +PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup." diff --git a/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini new file mode 100644 index 0000000..9fa5c15 --- /dev/null +++ b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Console Plugin language file (en-US) +PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup" +PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup." diff --git a/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini new file mode 100644 index 0000000..d22c08c --- /dev/null +++ b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Console Plugin system language file (en-US) +PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup" +PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup." diff --git a/src/packages/plg_console_mokobackup/mokobackup.php b/src/packages/plg_console_mokobackup/mokobackup.php new file mode 100644 index 0000000..724a1bb --- /dev/null +++ b/src/packages/plg_console_mokobackup/mokobackup.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_console_mokobackup/mokobackup.xml b/src/packages/plg_console_mokobackup/mokobackup.xml new file mode 100644 index 0000000..262f1c7 --- /dev/null +++ b/src/packages/plg_console_mokobackup/mokobackup.xml @@ -0,0 +1,32 @@ + + + + plg_console_mokobackup + 01.01.07-dev + 2026-06-04 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_CONSOLE_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\Console\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_console_mokobackup.ini + language/en-GB/plg_console_mokobackup.sys.ini + + diff --git a/src/packages/plg_console_mokobackup/services/provider.php b/src/packages/plg_console_mokobackup/services/provider.php new file mode 100644 index 0000000..3bacb2f --- /dev/null +++ b/src/packages/plg_console_mokobackup/services/provider.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 + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\Console\MokoBackup\Extension\MokoBackupConsole; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackupConsole( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('console', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php b/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php new file mode 100644 index 0000000..1a8509a --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php @@ -0,0 +1,125 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class CleanupCommand extends AbstractCommand +{ + protected static $defaultName = 'mokobackup:cleanup'; + + protected function configure(): void + { + $this->setDescription('Clean up old backup records and archive files'); + $this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Max age in days', '30'); + $this->addOption('max-count', null, InputOption::VALUE_REQUIRED, 'Max number of backups to keep', '10'); + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without deleting'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $maxAge = (int) $input->getOption('max-age'); + $maxCount = (int) $input->getOption('max-count'); + $dryRun = $input->getOption('dry-run'); + + $io->title('MokoJoomBackup — Cleanup'); + + if ($dryRun) { + $io->note('Dry run — no files will be deleted.'); + } + + $db = Factory::getDbo(); + $deleted = 0; + + // Delete by age + $cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days")); + $query = $db->getQuery(true) + ->select('id, absolute_path, description, backupstart') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $expired = $db->loadObjectList(); + + foreach ($expired as $record) { + $io->text('Expired: #' . $record->id . ' — ' . $record->backupstart . ' — ' . ($record->description ?: 'no description')); + + if (!$dryRun) { + if (!empty($record->absolute_path) && is_file($record->absolute_path)) { + @unlink($record->absolute_path); + } + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $record->id) + ); + $db->execute(); + } + + $deleted++; + } + + // Enforce max count + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $totalCount = (int) $db->loadResult(); + + if ($totalCount > $maxCount) { + $excess = $totalCount - $maxCount; + $query = $db->getQuery(true) + ->select('id, absolute_path, description, backupstart') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ->order($db->quoteName('backupstart') . ' ASC'); + $db->setQuery($query, 0, $excess); + $oldest = $db->loadObjectList(); + + foreach ($oldest as $record) { + $io->text('Over limit: #' . $record->id . ' — ' . $record->backupstart); + + if (!$dryRun) { + if (!empty($record->absolute_path) && is_file($record->absolute_path)) { + @unlink($record->absolute_path); + } + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $record->id) + ); + $db->execute(); + } + + $deleted++; + } + } + + if ($deleted === 0) { + $io->success('No backups to clean up.'); + } else { + $io->success(($dryRun ? 'Would delete ' : 'Deleted ') . $deleted . ' backup record(s).'); + } + + return 0; + } +} diff --git a/src/packages/plg_console_mokobackup/src/Command/ListCommand.php b/src/packages/plg_console_mokobackup/src/Command/ListCommand.php new file mode 100644 index 0000000..9586339 --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Command/ListCommand.php @@ -0,0 +1,87 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class ListCommand extends AbstractCommand +{ + protected static $defaultName = 'mokobackup:list'; + + protected function configure(): void + { + $this->setDescription('List backup records'); + $this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Number of records to show', '20'); + $this->addOption('status', 's', InputOption::VALUE_OPTIONAL, 'Filter by status (complete, fail, running)'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $limit = (int) $input->getOption('limit'); + $status = $input->getOption('status'); + + $io->title('MokoJoomBackup — Backup Records'); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('r.id, r.description, r.status, r.origin, r.backup_type, r.total_size, r.backupstart, r.backupend') + ->select($db->quoteName('p.title', 'profile_title')) + ->from($db->quoteName('#__mokobackup_records', 'r')) + ->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id') + ->order($db->quoteName('r.backupstart') . ' DESC'); + + if ($status) { + $query->where($db->quoteName('r.status') . ' = ' . $db->quote($status)); + } + + $db->setQuery($query, 0, $limit); + $records = $db->loadObjectList(); + + if (empty($records)) { + $io->info('No backup records found.'); + + return 0; + } + + $rows = []; + + foreach ($records as $record) { + $size = $record->total_size > 0 + ? round($record->total_size / 1048576, 2) . ' MB' + : '—'; + + $rows[] = [ + $record->id, + $record->profile_title ?: '—', + $record->status, + $record->backup_type, + $size, + $record->origin, + $record->backupstart, + ]; + } + + $io->table( + ['ID', 'Profile', 'Status', 'Type', 'Size', 'Origin', 'Started'], + $rows + ); + + return 0; + } +} diff --git a/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php b/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php new file mode 100644 index 0000000..8f4b21c --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class ProfilesCommand extends AbstractCommand +{ + protected static $defaultName = 'mokobackup:profiles'; + + protected function configure(): void + { + $this->setDescription('List available backup profiles'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('MokoJoomBackup — Backup Profiles'); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('id, title, backup_type, published, ordering') + ->from($db->quoteName('#__mokobackup_profiles')) + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $profiles = $db->loadObjectList(); + + if (empty($profiles)) { + $io->info('No backup profiles found.'); + + return 0; + } + + $rows = []; + + foreach ($profiles as $profile) { + $rows[] = [ + $profile->id, + $profile->title, + $profile->backup_type, + $profile->published ? 'Yes' : 'No', + ]; + } + + $io->table( + ['ID', 'Title', 'Type', 'Published'], + $rows + ); + + return 0; + } +} diff --git a/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php b/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php new file mode 100644 index 0000000..e5f9082 --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php @@ -0,0 +1,101 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class RestoreCommand extends AbstractCommand +{ + protected static $defaultName = 'mokobackup:restore'; + + protected function configure(): void + { + $this->setDescription('Restore a backup by record ID'); + $this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $recordId = (int) $input->getArgument('id'); + + $io->title('MokoJoomBackup — Restore Backup'); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('id') . ' = ' . $recordId); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + $io->error('Backup record not found: ' . $recordId); + + return 1; + } + + if ($record->status !== 'complete') { + $io->error('Cannot restore — backup status is: ' . $record->status); + + return 1; + } + + if (empty($record->absolute_path) || !is_file($record->absolute_path)) { + $io->error('Backup archive not found: ' . ($record->absolute_path ?: 'no path')); + + return 1; + } + + $io->warning('This will overwrite the current site files and/or database.'); + $io->text('Archive: ' . $record->absolute_path); + $io->text('Type: ' . $record->backup_type); + + if (!$io->confirm('Are you sure you want to continue?', false)) { + $io->info('Restore cancelled.'); + + return 0; + } + + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/RestoreEngine.php'; + + if (!file_exists($engineFile)) { + $io->error('RestoreEngine not found. Is the component fully installed?'); + + return 1; + } + + if (!class_exists(RestoreEngine::class)) { + require_once $engineFile; + } + + $engine = new RestoreEngine(); + $result = $engine->restore($record->absolute_path, $record->backup_type); + + if ($result['success']) { + $io->success($result['message']); + + return 0; + } + + $io->error($result['message']); + + return 1; + } +} diff --git a/src/packages/plg_console_mokobackup/src/Command/RunCommand.php b/src/packages/plg_console_mokobackup/src/Command/RunCommand.php new file mode 100644 index 0000000..d187737 --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Command/RunCommand.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class RunCommand extends AbstractCommand +{ + protected static $defaultName = 'mokobackup:run'; + + protected function configure(): void + { + $this->setDescription('Run a backup using a specified profile'); + $this->addOption('profile', 'p', InputOption::VALUE_REQUIRED, 'Profile ID to use', '1'); + $this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Backup description', ''); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $profileId = (int) $input->getOption('profile'); + $desc = $input->getOption('description') ?: ''; + + $io->title('MokoJoomBackup — Run Backup'); + $io->text('Profile ID: ' . $profileId); + + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php'; + + if (!file_exists($engineFile)) { + $io->error('MokoJoomBackup component not installed.'); + + return 1; + } + + if (!class_exists(BackupEngine::class)) { + require_once $engineFile; + } + + $engine = new BackupEngine(); + $result = $engine->run($profileId, $desc ?: 'CLI backup', 'cli'); + + if ($result['success']) { + $io->success($result['message']); + + return 0; + } + + $io->error($result['message']); + + return 1; + } +} diff --git a/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php b/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php new file mode 100644 index 0000000..fca96ac --- /dev/null +++ b/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php @@ -0,0 +1,45 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; +use Joomla\Plugin\Console\MokoBackup\Command\CleanupCommand; +use Joomla\Plugin\Console\MokoBackup\Command\ListCommand; +use Joomla\Plugin\Console\MokoBackup\Command\ProfilesCommand; +use Joomla\Plugin\Console\MokoBackup\Command\RestoreCommand; +use Joomla\Plugin\Console\MokoBackup\Command\RunCommand; + +final class MokoBackupConsole extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + \Joomla\Application\ApplicationEvents::BEFORE_EXECUTE => 'registerCommands', + ]; + } + + public function registerCommands(Event $event): void + { + $app = $this->getApplication(); + + $app->addCommand(new RunCommand()); + $app->addCommand(new ListCommand()); + $app->addCommand(new ProfilesCommand()); + $app->addCommand(new RestoreCommand()); + $app->addCommand(new CleanupCommand()); + } +} diff --git a/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini new file mode 100644 index 0000000..5f23262 --- /dev/null +++ b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Content Plugin language file (en-GB) +PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup" +PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates." +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install" +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed." +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update" +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated." +PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile" +PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups." diff --git a/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini new file mode 100644 index 0000000..3d79871 --- /dev/null +++ b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Content Plugin system language file (en-GB) +PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup" +PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates." diff --git a/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini new file mode 100644 index 0000000..1bac9a8 --- /dev/null +++ b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Content Plugin language file (en-US) +PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup" +PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates." +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install" +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed." +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update" +PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated." +PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile" +PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups." diff --git a/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini new file mode 100644 index 0000000..7a612b3 --- /dev/null +++ b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — Content Plugin system language file (en-US) +PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup" +PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates." diff --git a/src/packages/plg_content_mokobackup/mokobackup.php b/src/packages/plg_content_mokobackup/mokobackup.php new file mode 100644 index 0000000..2dd15e4 --- /dev/null +++ b/src/packages/plg_content_mokobackup/mokobackup.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_content_mokobackup/mokobackup.xml b/src/packages/plg_content_mokobackup/mokobackup.xml new file mode 100644 index 0000000..8548bda --- /dev/null +++ b/src/packages/plg_content_mokobackup/mokobackup.xml @@ -0,0 +1,71 @@ + + + + plg_content_mokobackup + 01.01.07-dev + 2026-06-04 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_CONTENT_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\Content\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_content_mokobackup.ini + language/en-GB/plg_content_mokobackup.sys.ini + + + + +
+ + + + + + + + + + + +
+
+
+
diff --git a/src/packages/plg_content_mokobackup/services/provider.php b/src/packages/plg_content_mokobackup/services/provider.php new file mode 100644 index 0000000..4635162 --- /dev/null +++ b/src/packages/plg_content_mokobackup/services/provider.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 + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\Content\MokoBackup\Extension\MokoBackupContent; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackupContent( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('content', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php b/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php new file mode 100644 index 0000000..b27d119 --- /dev/null +++ b/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php @@ -0,0 +1,95 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Content\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; + +final class MokoBackupContent extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onExtensionBeforeInstall' => 'onExtensionBeforeInstall', + 'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate', + ]; + } + + /** + * Trigger a backup before a new extension is installed. + */ + public function onExtensionBeforeInstall(Event $event): void + { + if (!(int) $this->params->get('backup_before_install', 0)) { + return; + } + + $this->triggerAutoBackup('Pre-install backup'); + } + + /** + * Trigger a backup before an extension is updated. + */ + public function onExtensionBeforeUpdate(Event $event): void + { + if (!(int) $this->params->get('backup_before_update', 1)) { + return; + } + + $this->triggerAutoBackup('Pre-update backup'); + } + + /** + * Run a backup using the configured profile. + */ + private function triggerAutoBackup(string $description): void + { + $profileId = (int) $this->params->get('profile_id', 1); + + // Throttle: only one auto-backup per hour via session + $session = Factory::getSession(); + $lastRun = $session->get('mokobackup.content_last_autobackup', 0); + + if (time() - $lastRun < 3600) { + return; + } + + $session->set('mokobackup.content_last_autobackup', time()); + + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php'; + + if (!file_exists($engineFile)) { + return; + } + + if (!class_exists(BackupEngine::class)) { + require_once $engineFile; + } + + try { + $engine = new BackupEngine(); + $engine->run($profileId, $description, 'backend'); + } catch (\Throwable $e) { + // Non-fatal — log and continue with the install/update + Factory::getApplication()->enqueueMessage( + 'MokoJoomBackup auto-backup failed: ' . $e->getMessage(), + 'warning' + ); + } + } +} diff --git a/src/packages/plg_quickicon_mokobackup/mokobackup.xml b/src/packages/plg_quickicon_mokobackup/mokobackup.xml index b2ff32a..4341a06 100644 --- a/src/packages/plg_quickicon_mokobackup/mokobackup.xml +++ b/src/packages/plg_quickicon_mokobackup/mokobackup.xml @@ -1,7 +1,7 @@ plg_quickicon_mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php index 5d0bd9a..c72cda5 100644 --- a/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php +++ b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php @@ -15,6 +15,7 @@ namespace Joomla\Plugin\Quickicon\MokoBackup\Extension; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Event\Event; use Joomla\Event\SubscriberInterface; @@ -96,7 +97,7 @@ final class MokoBackupQuickicon extends CMSPlugin implements SubscriberInterface 'link' => 'index.php?option=com_mokobackup&view=backups', 'image' => $warning ? 'icon-warning' : 'icon-database', 'icon' => $warning ? 'icon-warning' : 'icon-database', - 'text' => $text, + 'text' => Text::_($text), 'linkadd' => $subtitle ? '
' . htmlspecialchars($subtitle) . '' : '', 'id' => 'plg_quickicon_mokobackup', 'group' => 'MOD_QUICKICON_MAINTENANCE', diff --git a/src/packages/plg_system_mokobackup/mokobackup.xml b/src/packages/plg_system_mokobackup/mokobackup.xml index f13d733..899f088 100644 --- a/src/packages/plg_system_mokobackup/mokobackup.xml +++ b/src/packages/plg_system_mokobackup/mokobackup.xml @@ -8,7 +8,7 @@ --> plg_system_mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_task_mokobackup/mokobackup.xml b/src/packages/plg_task_mokobackup/mokobackup.xml index 92b274e..cfb1ffc 100644 --- a/src/packages/plg_task_mokobackup/mokobackup.xml +++ b/src/packages/plg_task_mokobackup/mokobackup.xml @@ -8,7 +8,7 @@ --> plg_task_mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_webservices_mokobackup/mokobackup.xml b/src/packages/plg_webservices_mokobackup/mokobackup.xml index 5d4f103..5ae3520 100644 --- a/src/packages/plg_webservices_mokobackup/mokobackup.xml +++ b/src/packages/plg_webservices_mokobackup/mokobackup.xml @@ -8,7 +8,7 @@ --> plg_webservices_mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/src/pkg_mokobackup.xml b/src/pkg_mokobackup.xml index 334846c..86dd028 100644 --- a/src/pkg_mokobackup.xml +++ b/src/pkg_mokobackup.xml @@ -8,7 +8,7 @@ Package - MokoJoomBackup mokobackup - 01.00.00 + 01.01.07-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -25,6 +25,9 @@ plg_task_mokobackup.zip plg_quickicon_mokobackup.zip plg_webservices_mokobackup.zip + plg_console_mokobackup.zip + plg_content_mokobackup.zip + plg_actionlog_mokobackup.zip @@ -32,6 +35,8 @@ - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/main/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/updates.xml + + true diff --git a/src/script.php b/src/script.php index ed0db68..d970bcd 100644 --- a/src/script.php +++ b/src/script.php @@ -12,6 +12,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Installer\InstallerAdapter; use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; class Pkg_MokoBackupInstallerScript { @@ -107,6 +108,39 @@ class Pkg_MokoBackupInstallerScript $db->setQuery($query); $db->execute(); + // Enable the console plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('console')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + + // Enable the content plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('content')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + + // Enable the actionlog plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('actionlog')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + // Create default backup directory $backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups'; @@ -118,5 +152,54 @@ class Pkg_MokoBackupInstallerScript file_put_contents($backupDir . '/index.html', ''); } } + + // Show update site link after install or update + $this->showUpdateSiteNotice(); + } + + /** + * Show an info message linking directly to the update site record + * so the user can configure their download key. + * + * @return void + */ + private function showUpdateSiteNotice(): void + { + try { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('us.update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join( + 'INNER', + $db->quoteName('#__update_sites_extensions', 'use') + . ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id') + ) + ->join( + 'INNER', + $db->quoteName('#__extensions', 'e') + . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id') + ) + ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup')) + ->where($db->quoteName('e.type') . ' = ' . $db->quote('package')) + ->setLimit(1); + + $db->setQuery($query); + $updateSiteId = (int) $db->loadResult(); + + if ($updateSiteId > 0) { + $editUrl = Route::_( + 'index.php?option=com_installer&view=updatesites&filter[search]=mokobackup' + ); + + Factory::getApplication()->enqueueMessage( + Text::sprintf('PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE', $editUrl), + 'info' + ); + } + } catch (\Throwable $e) { + // Non-critical — silently ignore + } } } diff --git a/updates.xml b/updates.xml deleted file mode 100644 index 5ba8cf1..0000000 --- a/updates.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - Package - MokoJoomBackup - Full-site backup and restore for Joomla - mokobackup - package - 01.00.00 - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/tag/v01.00.00 - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/download/v01.00.00/pkg_mokobackup-01.00.00.zip - - - 8.1.0 - -