Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a13f7ca6a6 | |||
| 28fcc72ced | |||
| fdd004b345 | |||
| 2755ef709f | |||
| df59fd7303 | |||
| b1632e790e | |||
| 3c469f0dae | |||
| 75e72c248a | |||
| cf50551595 | |||
| 368461577e | |||
| fea1800e06 | |||
| d19274d88b | |||
| 26635f933c | |||
| 8d80c218da | |||
| 807da034c9 | |||
| e4d704dd84 | |||
| a805351dd1 | |||
| c5d4445bc1 | |||
| 5460c7b211 | |||
| 5ab6296ad5 | |||
| c1521cb235 | |||
| 25e06fd08c | |||
| 61abf72437 | |||
| 8c5ed1ed76 | |||
| e9d7889417 | |||
| 4c15f12426 | |||
| 8d082de47f | |||
| 41c1bb5d68 | |||
| 4f14009003 | |||
| 8e51abee54 | |||
| f884314e28 | |||
| e495c786fb | |||
| c3cbad1a00 | |||
| a34b715cff | |||
| 3ded91608a | |||
| 119379bb16 | |||
| 8eab341c1e | |||
| 70df427cfe | |||
| 2aee667d00 | |||
| 6bab1ad5fa | |||
| 5ee4f7a578 | |||
| 44e309c57f | |||
| 6ed0eee4a1 | |||
| 3d88281e72 | |||
| 1f225689ba | |||
| e5ca71f2c5 | |||
| 610f875ad9 | |||
| 507b2c9448 | |||
| aa36a01a7f |
+1
-1
@@ -151,7 +151,7 @@ package-lock.json
|
||||
# PHP / Composer tooling
|
||||
# ============================================================
|
||||
vendor/
|
||||
!src/media/vendor/
|
||||
!source/media/vendor/
|
||||
composer.lock
|
||||
*.phar
|
||||
codeception.phar
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<display-name>Package - MokoJoomBackup</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
|
||||
<version>01.00.00</version>
|
||||
<version>01.01.07-dev</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
@@ -16,6 +16,6 @@
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
<entry-point>source/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoPlatform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete merged branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref != 'dev' &&
|
||||
github.event.pull_request.head.ref != 'main'
|
||||
|
||||
steps:
|
||||
- name: Delete source branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: PHP syntax check
|
||||
run: |
|
||||
ERRORS=0
|
||||
for DIR in src/ htdocs/; do
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
FOUND=1
|
||||
while IFS= read -r -d '' FILE; do
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
echo "### Language Directory Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
for DIR in src/ htdocs/; do
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] || continue
|
||||
# Find all language directories
|
||||
while IFS= read -r -d '' LANG_DIR; do
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
MISSING=0
|
||||
CHECKED=0
|
||||
|
||||
for DIR in src/ htdocs/; do
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
while IFS= read -r -d '' SUBDIR; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ "${CHECKED}" -eq 0 ]; then
|
||||
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
echo "No source/, src/, or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${MISSING}" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -450,7 +450,7 @@ jobs:
|
||||
|
||||
# Determine source directory
|
||||
SRC_DIR=""
|
||||
for DIR in src/ htdocs/ lib/; do
|
||||
for DIR in source/ src/ htdocs/ lib/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
SRC_DIR="$DIR"
|
||||
break
|
||||
@@ -458,7 +458,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
echo "No source directory found (source/, src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# +========================================================================+
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "*/source/*" -o -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 source/ 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 '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</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
|
||||
@@ -207,10 +451,11 @@ jobs:
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
echo "::warning::No source/, src/, or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.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
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
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_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f “/opt/moko-platform/cli/version_bump.php” ] && [ -f “/opt/moko-platform/vendor/autoload.php” ]; then
|
||||
echo “Using pre-installed /opt/moko-platform”
|
||||
echo “MOKO_CLI=/opt/moko-platform/cli” >> “$GITHUB_ENV”
|
||||
else
|
||||
echo “Falling back to fresh clone”
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
“https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git” \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo “MOKO_CLI=/tmp/moko-platform-api/cli” >> “$GITHUB_ENV”
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# 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 "suffix=${SUFFIX}" >> "$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}${SUFFIX} ==="
|
||||
|
||||
- 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: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
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
|
||||
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
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
|
||||
@@ -396,17 +396,19 @@ jobs:
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
# Source directory: source/, src/, or htdocs/ (any is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
if [ -d "source" ]; then
|
||||
SOURCE_DIR="source"
|
||||
elif [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
# Platform/tooling repos don't need source/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
missing_required+=("source/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
|
||||
@@ -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"
|
||||
|
||||
+29
-36
@@ -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
|
||||
|
||||
@@ -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
|
||||
SRC_DIR := source
|
||||
|
||||
# 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 '<version>' $(SRC_DIR)/pkg_mokobackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
|
||||
echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoJoomBackup
|
||||
|
||||
<!-- VERSION: 01.00.00 -->
|
||||
<!-- VERSION: 01.01.07 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
+1
@@ -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 <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
+1
@@ -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 <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
-->
|
||||
<config>
|
||||
<fieldset name="general" label="COM_MOKOBACKUP_CONFIG_GENERAL">
|
||||
<field
|
||||
name="default_backup_dir"
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
|
||||
description="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokobackup/backups"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="default_profile"
|
||||
type="sql"
|
||||
label="COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||
description="COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
|
||||
query="SELECT id AS value, title AS text FROM #__mokobackup_profiles WHERE published = 1 ORDER BY ordering ASC"
|
||||
default="1"
|
||||
>
|
||||
<option value="1">Default Backup Profile</option>
|
||||
</field>
|
||||
<field
|
||||
name="show_update_notice"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE"
|
||||
description="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="cleanup" label="COM_MOKOBACKUP_CONFIG_CLEANUP">
|
||||
<field
|
||||
name="max_age_days"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_CONFIG_MAX_AGE"
|
||||
description="COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC"
|
||||
default="30"
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<field
|
||||
name="max_backups"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS"
|
||||
description="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC"
|
||||
default="10"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="notifications" label="COM_MOKOBACKUP_CONFIG_NOTIFICATIONS">
|
||||
<field
|
||||
name="notify_email"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="notify_on_success"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="notify_on_failure"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
||||
description="JCONFIG_PERMISSIONS_DESC">
|
||||
<field
|
||||
name="rules"
|
||||
type="rules"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
filter="rules"
|
||||
validate="rules"
|
||||
component="com_mokobackup"
|
||||
section="component"
|
||||
/>
|
||||
</fieldset>
|
||||
</config>
|
||||
+31
-14
@@ -39,6 +39,7 @@
|
||||
default="zip"
|
||||
>
|
||||
<option value="zip">ZIP</option>
|
||||
<option value="tar.gz">tar.gz</option>
|
||||
</field>
|
||||
<field
|
||||
name="compression_level"
|
||||
@@ -63,17 +64,26 @@
|
||||
/>
|
||||
<field
|
||||
name="backup_dir"
|
||||
type="text"
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOBACKUP_FIELD_BACKUP_DIR"
|
||||
description="COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokobackup/backups"
|
||||
maxlength="512"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="include_kickstart"
|
||||
name="archive_name_format"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
default="[host]_[datetime]_profile[profile_id]"
|
||||
maxlength="512"
|
||||
hint="[host]_[datetime]_profile[profile_id]"
|
||||
/>
|
||||
<field
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART"
|
||||
description="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC"
|
||||
label="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
@@ -114,30 +124,29 @@
|
||||
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
|
||||
<field
|
||||
name="exclude_dirs"
|
||||
type="textarea"
|
||||
type="DirectoryFilter"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
|
||||
rows="6"
|
||||
filter="raw"
|
||||
hint="tmp cache logs administrator/logs"
|
||||
hint="tmp"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_files"
|
||||
type="textarea"
|
||||
type="ExcludeList"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC"
|
||||
rows="4"
|
||||
filter="raw"
|
||||
hint=".gitignore *.bak *.tmp"
|
||||
hint="*.bak"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_tables"
|
||||
type="textarea"
|
||||
type="DatabaseTables"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC"
|
||||
rows="4"
|
||||
filter="raw"
|
||||
hint="#__session #__mail_queue"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -176,6 +185,14 @@
|
||||
maxlength="512"
|
||||
hint="admin@example.com, backup@example.com"
|
||||
/>
|
||||
<field
|
||||
name="notify_user_groups"
|
||||
type="usergrouplist"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select"
|
||||
/>
|
||||
<field
|
||||
name="notify_on_success"
|
||||
type="radio"
|
||||
+74
-4
@@ -8,9 +8,24 @@ COM_MOKOBACKUP="MokoJoomBackup"
|
||||
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||
|
||||
; Submenu
|
||||
COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
|
||||
|
||||
; Dashboard view
|
||||
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"
|
||||
|
||||
; Backups view
|
||||
COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records"
|
||||
COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records"
|
||||
@@ -20,6 +35,11 @@ COM_MOKOBACKUP_DOWNLOAD="Download"
|
||||
|
||||
; Backup detail view
|
||||
COM_MOKOBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||
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"
|
||||
|
||||
; Profiles view
|
||||
COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
@@ -74,13 +94,18 @@ COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the bac
|
||||
COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||
COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Relative path from Joomla root where backup archives are stored"
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART="Include Restore Script"
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC="Include a standalone restore.php inside the backup archive. This creates a self-contained package that can restore the site on a blank server without Joomla installed — like Akeeba Kickstart."
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC="One directory path per line (relative to Joomla root). These directories will be skipped during file backup."
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
|
||||
COM_MOKOBACKUP_FILTER_EXCLUDED="Excluded"
|
||||
COM_MOKOBACKUP_FILTER_INCLUDED="Included"
|
||||
COM_MOKOBACKUP_FILTER_ADD_MANUAL="Add Path"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES="Exclude Files"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC="One filename or pattern per line. Supports wildcards (e.g. *.bak, *.tmp)."
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES="Exclude Tables"
|
||||
@@ -189,6 +214,51 @@ COM_MOKOBACKUP_FIELD_S3_PATH_DESC="Optional path prefix inside the bucket (e.g.
|
||||
COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba"
|
||||
COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?"
|
||||
|
||||
; Update site notice
|
||||
COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> 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 <a href=\"%s\">Update Site</a> 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."
|
||||
@@ -0,0 +1,69 @@
|
||||
; MokoJoomBackup — Component language file (en-US)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
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 <a href=\"%s\">Update Site</a> 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 <a href=\"%s\">Update Site</a> 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."
|
||||
+14
-37
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokobackup</name>
|
||||
<version>01.00.00</version>
|
||||
<version>01.01.07-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
<namespace path="src">Joomla\Component\MokoBackup</namespace>
|
||||
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
<install>
|
||||
<sql>
|
||||
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
|
||||
@@ -40,45 +38,24 @@
|
||||
</update>
|
||||
|
||||
<administration>
|
||||
<files folder="services">
|
||||
<filename>provider.php</filename>
|
||||
</files>
|
||||
<files folder="src">
|
||||
<folder>Controller</folder>
|
||||
<folder>Engine</folder>
|
||||
<folder>Extension</folder>
|
||||
<folder>Model</folder>
|
||||
<folder>Table</folder>
|
||||
<folder>View</folder>
|
||||
</files>
|
||||
<files folder="forms">
|
||||
<filename>backup.xml</filename>
|
||||
<filename>profile.xml</filename>
|
||||
<filename>filter_backups.xml</filename>
|
||||
<filename>filter_profiles.xml</filename>
|
||||
</files>
|
||||
<files folder="tmpl">
|
||||
<folder>backups</folder>
|
||||
<folder>backup</folder>
|
||||
<folder>profiles</folder>
|
||||
<folder>profile</folder>
|
||||
</files>
|
||||
<files folder="sql">
|
||||
<folder>mysql</folder>
|
||||
<folder>updates</folder>
|
||||
</files>
|
||||
<files folder="cli">
|
||||
<filename>mokobackup.php</filename>
|
||||
<menu img="class:archive">COM_MOKOBACKUP</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokobackup&view=dashboard" img="class:archive">COM_MOKOBACKUP_SUBMENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokobackup&view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokobackup&view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
|
||||
</submenu>
|
||||
<files folder=".">
|
||||
<folder>cli</folder>
|
||||
<folder>forms</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/com_mokobackup.ini</language>
|
||||
<language tag="en-GB">en-GB/com_mokobackup.sys.ini</language>
|
||||
</languages>
|
||||
<menu img="class:archive">COM_MOKOBACKUP</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokobackup&view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokobackup&view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
|
||||
</submenu>
|
||||
</administration>
|
||||
|
||||
<api>
|
||||
+5
-3
@@ -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`),
|
||||
@@ -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';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- MokoJoomBackup 01.01.02
|
||||
-- Consolidated schema updates: NULL defaults, notifications, archive name format
|
||||
|
||||
-- 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`;
|
||||
|
||||
-- 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`;
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* AJAX controller for step-based backups.
|
||||
* Handles init and step requests from the admin UI JavaScript.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\SteppedBackupEngine;
|
||||
|
||||
class AjaxController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Initialize a new stepped backup.
|
||||
* POST: task=ajax.init&profile_id=1&description=...
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 1);
|
||||
$description = $this->input->getString('description', '');
|
||||
|
||||
$engine = new SteppedBackupEngine();
|
||||
$result = $engine->init($profileId, $description, 'backend');
|
||||
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the next step of a backup session.
|
||||
* POST: task=ajax.step&session_id=mb_...
|
||||
*/
|
||||
public function step(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionId = $this->input->getString('session_id', '');
|
||||
|
||||
if (empty($sessionId)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SteppedBackupEngine();
|
||||
$result = $engine->runStep($sessionId);
|
||||
|
||||
$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.
|
||||
*/
|
||||
private function sendJson(array $data): void
|
||||
{
|
||||
$app = $this->app;
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
$app->sendHeaders();
|
||||
|
||||
echo json_encode($data);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
+1
-1
@@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'backups';
|
||||
protected $default_view = 'dashboard';
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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;
|
||||
}
|
||||
+93
-28
@@ -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;
|
||||
+84
-18
@@ -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;
|
||||
}
|
||||
+5
-5
@@ -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'
|
||||
<?php
|
||||
/**
|
||||
* MokoJoomBackup — Standalone Restore Script
|
||||
* MokoRestore — Standalone Site Restoration Tool
|
||||
*
|
||||
* Upload this file alongside site-backup.zip to your server.
|
||||
* Open restore.php in your browser and follow the steps.
|
||||
+46
-4
@@ -33,9 +33,13 @@ class NotificationSender
|
||||
*/
|
||||
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
|
||||
{
|
||||
$notifyEmail = trim($profile->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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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.
|
||||
*/
|
||||
+37
-17
@@ -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)) {
|
||||
+1
-1
@@ -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
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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 = '<div class="mb-2">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
|
||||
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
|
||||
$html .= '<table class="table table-sm table-hover mb-0">';
|
||||
$html .= '<thead class="sticky-top bg-white"><tr>';
|
||||
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleData" title="Toggle all data" /></th>';
|
||||
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_DATA') . '</th>';
|
||||
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleStructure" title="Toggle all structure" /></th>';
|
||||
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE') . '</th>';
|
||||
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
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 .= '<tr>';
|
||||
$html .= '<td></td>';
|
||||
$html .= '<td><input type="checkbox" class="' . $id . '_data" value="' . $safeValue . '"' . $dataChecked . ' /></td>';
|
||||
$html .= '<td></td>';
|
||||
$html .= '<td><input type="checkbox" class="' . $id . '_structure" value="' . $safeValue . '"' . $structureChecked . ' /></td>';
|
||||
$html .= '<td><code>' . $safeTable . '</code></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table></div></div>';
|
||||
|
||||
// Script to sync checkboxes to hidden field
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var dataCbs = document.querySelectorAll('.{$id}_data');
|
||||
var structCbs = document.querySelectorAll('.{$id}_structure');
|
||||
var toggleData = document.getElementById('{$id}_toggleData');
|
||||
var toggleStructure = document.getElementById('{$id}_toggleStructure');
|
||||
|
||||
function sync() {
|
||||
var result = {};
|
||||
dataCbs.forEach(function(cb) {
|
||||
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 1;
|
||||
});
|
||||
structCbs.forEach(function(cb) {
|
||||
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 2;
|
||||
});
|
||||
var lines = [];
|
||||
for (var table in result) {
|
||||
if (result[table] === 3) {
|
||||
lines.push(table);
|
||||
} else if (result[table] === 1) {
|
||||
lines.push(table + ':data-only');
|
||||
} else if (result[table] === 2) {
|
||||
lines.push(table + ':structure-only');
|
||||
}
|
||||
}
|
||||
hidden.value = lines.join('\\n');
|
||||
}
|
||||
|
||||
dataCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
|
||||
structCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
|
||||
|
||||
toggleData.addEventListener('change', function() {
|
||||
var state = this.checked;
|
||||
dataCbs.forEach(function(cb) { cb.checked = state; });
|
||||
sync();
|
||||
});
|
||||
|
||||
toggleStructure.addEventListener('change', function() {
|
||||
var state = this.checked;
|
||||
structCbs.forEach(function(cb) { cb.checked = state; });
|
||||
sync();
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* Interactive directory tree field with checkboxes for exclude/include filtering.
|
||||
* Loads the directory tree from the server via AJAX (browseDir endpoint).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class DirectoryFilterField extends FormField
|
||||
{
|
||||
protected $type = 'DirectoryFilter';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
$mode = htmlspecialchars((string) ($this->element['mode'] ?? 'exclude'), 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)))));
|
||||
}
|
||||
|
||||
$itemsJson = json_encode($items);
|
||||
$jRoot = json_encode(JPATH_ROOT);
|
||||
|
||||
$labelExclude = Text::_('COM_MOKOBACKUP_FILTER_EXCLUDED');
|
||||
$labelInclude = Text::_('COM_MOKOBACKUP_FILTER_INCLUDED');
|
||||
$labelManual = Text::_('COM_MOKOBACKUP_FILTER_ADD_MANUAL');
|
||||
$addLabel = Text::_('JGLOBAL_FIELD_ADD');
|
||||
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? 'path/to/directory'), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div id="{$id}_wrap">
|
||||
<input type="hidden" name="{$name}" id="{$id}" value="" />
|
||||
|
||||
<!-- Manual entry row -->
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" id="{$id}_manual" placeholder="{$placeholder}" />
|
||||
<button type="button" class="btn btn-outline-success" id="{$id}_addBtn">
|
||||
<span class="icon-plus" aria-hidden="true"></span> {$addLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected items (pills) -->
|
||||
<div id="{$id}_pills" class="mb-2 d-flex flex-wrap gap-1"></div>
|
||||
|
||||
<!-- Browsable tree -->
|
||||
<div class="card">
|
||||
<div class="card-header py-1 px-2 d-flex justify-content-between align-items-center">
|
||||
<small class="fw-bold text-muted" id="{$id}_cwd"></small>
|
||||
<button type="button" class="btn btn-sm btn-link p-0" id="{$id}_upBtn" style="display:none;">
|
||||
<span class="icon-arrow-up-4" aria-hidden="true"></span> ..
|
||||
</button>
|
||||
</div>
|
||||
<div id="{$id}_tree" class="list-group list-group-flush" style="max-height:300px; overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#{$id}_wrap .mb-dir-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
|
||||
font-family: monospace; cursor: default;
|
||||
}
|
||||
#{$id}_wrap .mb-dir-pill.excluded { background: #f8d7da; color: #842029; border: 1px solid #f5c2c7; }
|
||||
#{$id}_wrap .mb-dir-pill.included { background: #d1e7dd; color: #0f5132; border: 1px solid #badbcc; }
|
||||
#{$id}_wrap .mb-dir-pill .btn-close { font-size: 0.6rem; }
|
||||
#{$id}_wrap .mb-dir-row { display: flex; align-items: center; padding: 0.35rem 0.75rem; gap: 0.5rem; border-bottom: 1px solid #eee; }
|
||||
#{$id}_wrap .mb-dir-row:hover { background: #f8f9fa; }
|
||||
#{$id}_wrap .mb-dir-row .mb-dir-name { cursor: pointer; flex: 1; font-size: 0.9rem; }
|
||||
#{$id}_wrap .mb-dir-row .mb-dir-name:hover { color: #0d6efd; text-decoration: underline; }
|
||||
#{$id}_wrap .mb-dir-check { width: 1rem; height: 1rem; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const id = '{$id}';
|
||||
const hidden = document.getElementById(id);
|
||||
const pills = document.getElementById(id + '_pills');
|
||||
const tree = document.getElementById(id + '_tree');
|
||||
const cwdEl = document.getElementById(id + '_cwd');
|
||||
const upBtn = document.getElementById(id + '_upBtn');
|
||||
const manualInput = document.getElementById(id + '_manual');
|
||||
const addBtn = document.getElementById(id + '_addBtn');
|
||||
const jRoot = {$jRoot};
|
||||
|
||||
let selected = new Set({$itemsJson});
|
||||
let currentPath = jRoot;
|
||||
let parentPath = null;
|
||||
|
||||
function sync() {
|
||||
hidden.value = Array.from(selected).join('\\n');
|
||||
renderPills();
|
||||
}
|
||||
|
||||
function renderPills() {
|
||||
while (pills.firstChild) pills.removeChild(pills.firstChild);
|
||||
selected.forEach(function(path) {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mb-dir-pill excluded';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'icon-folder';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
pill.appendChild(icon);
|
||||
|
||||
pill.appendChild(document.createTextNode(' ' + path + ' '));
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'btn-close btn-close-sm';
|
||||
closeBtn.setAttribute('aria-label', 'Remove');
|
||||
closeBtn.addEventListener('click', function() {
|
||||
selected.delete(path);
|
||||
sync();
|
||||
refreshTree();
|
||||
});
|
||||
pill.appendChild(closeBtn);
|
||||
|
||||
pills.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
function toRelative(absPath) {
|
||||
if (absPath.indexOf(jRoot) === 0) {
|
||||
let rel = absPath.substring(jRoot.length);
|
||||
if (rel.charAt(0) === '/') rel = rel.substring(1);
|
||||
return rel;
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
|
||||
function setTreeMessage(text, cls) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'p-2 ' + cls;
|
||||
msg.textContent = text;
|
||||
tree.appendChild(msg);
|
||||
}
|
||||
|
||||
function loadDir(path) {
|
||||
setTreeMessage('Loading...', 'text-muted');
|
||||
currentPath = path;
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseDir');
|
||||
form.append('path', path);
|
||||
const tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokobackup&format=json', {
|
||||
method: 'POST', body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
setTreeMessage(data.message || 'Error', 'text-danger');
|
||||
return;
|
||||
}
|
||||
parentPath = data.parent || null;
|
||||
cwdEl.textContent = data.current || path;
|
||||
upBtn.style.display = parentPath ? '' : 'none';
|
||||
renderTree(data.dirs || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
setTreeMessage('Error: ' + err.message, 'text-danger');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshTree() {
|
||||
loadDir(currentPath);
|
||||
}
|
||||
|
||||
function renderTree(dirs) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
if (dirs.length === 0) {
|
||||
setTreeMessage('(empty)', 'text-muted');
|
||||
return;
|
||||
}
|
||||
|
||||
dirs.forEach(function(dir) {
|
||||
const rel = toRelative(dir.path);
|
||||
const isExcluded = selected.has(rel);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mb-dir-row' + (isExcluded ? ' bg-danger bg-opacity-10' : '');
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'mb-dir-check form-check-input';
|
||||
cb.checked = isExcluded;
|
||||
cb.title = isExcluded ? 'Excluded — uncheck to include' : 'Check to exclude';
|
||||
cb.addEventListener('change', function() {
|
||||
if (cb.checked) {
|
||||
selected.add(rel);
|
||||
} else {
|
||||
selected.delete(rel);
|
||||
}
|
||||
sync();
|
||||
refreshTree();
|
||||
});
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = isExcluded ? 'icon-unpublish text-danger' : 'icon-folder text-warning';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'mb-dir-name';
|
||||
nameEl.textContent = dir.name;
|
||||
nameEl.addEventListener('click', function() { loadDir(dir.path); });
|
||||
|
||||
row.appendChild(cb);
|
||||
row.appendChild(icon);
|
||||
row.appendChild(nameEl);
|
||||
tree.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
upBtn.addEventListener('click', function() {
|
||||
if (parentPath) loadDir(parentPath);
|
||||
});
|
||||
|
||||
addBtn.addEventListener('click', function() {
|
||||
const val = manualInput.value.trim();
|
||||
if (val && !selected.has(val)) {
|
||||
selected.add(val);
|
||||
manualInput.value = '';
|
||||
sync();
|
||||
refreshTree();
|
||||
}
|
||||
});
|
||||
|
||||
manualInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
|
||||
});
|
||||
|
||||
sync();
|
||||
loadDir(jRoot);
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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 = '<div id="' . $id . '_wrapper">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
|
||||
$html .= '<tbody>';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
|
||||
$html .= '<tr>';
|
||||
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
|
||||
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
|
||||
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var wrapper = document.getElementById('{$id}_wrapper');
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var tbody = document.querySelector('#{$id}_table tbody');
|
||||
var addBtn = document.getElementById('{$id}_add');
|
||||
var placeholder = '{$placeholder}';
|
||||
|
||||
function sync() {
|
||||
var vals = [];
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
var v = inp.value.trim();
|
||||
if (v) vals.push(v);
|
||||
});
|
||||
hidden.value = vals.join('\\n');
|
||||
}
|
||||
|
||||
function addRow(value) {
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm {$id}_input';
|
||||
inp.value = value || '';
|
||||
inp.placeholder = placeholder;
|
||||
inp.addEventListener('input', sync);
|
||||
td1.appendChild(inp);
|
||||
|
||||
var td2 = document.createElement('td');
|
||||
td2.className = 'w-1';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-delete';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener('click', function() { tr.remove(); sync(); });
|
||||
td2.appendChild(btn);
|
||||
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', function() { addRow(''); });
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
inp.addEventListener('input', sync);
|
||||
});
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.closest('tr').remove();
|
||||
sync();
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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 <<<HTML
|
||||
<div class="input-group">
|
||||
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||
class="form-control" maxlength="512"
|
||||
placeholder="/home/user/backups or administrator/components/com_mokobackup/backups" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<small class="{$statusClass}">
|
||||
<span class="{$statusIcon}" aria-hidden="true"></span>
|
||||
{$statusText}: <code>{$absPathSafe}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
|
||||
<div class="card-body p-2">
|
||||
<div id="{$id}_tree"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var fieldId = '{$id}';
|
||||
var btn = document.getElementById(fieldId + '_btn');
|
||||
var browser = document.getElementById(fieldId + '_browser');
|
||||
var tree = document.getElementById(fieldId + '_tree');
|
||||
var input = document.getElementById(fieldId);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (browser.style.display !== 'none') {
|
||||
browser.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
browser.style.display = 'block';
|
||||
loadDir(input.value || '/');
|
||||
});
|
||||
|
||||
function loadDir(path) {
|
||||
tree.textContent = 'Loading...';
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseDir');
|
||||
form.append('path', path);
|
||||
|
||||
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokobackup&format=json', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
tree.textContent = data.message || 'Error loading directory';
|
||||
return;
|
||||
}
|
||||
renderTree(data, path);
|
||||
})
|
||||
.catch(function(err) {
|
||||
tree.textContent = 'Error: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTree(data, path) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
|
||||
var list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
|
||||
if (data.parent) {
|
||||
var up = document.createElement('a');
|
||||
up.href = '#';
|
||||
up.className = 'list-group-item list-group-item-action py-1';
|
||||
var upIcon = document.createElement('span');
|
||||
upIcon.className = 'icon-arrow-up-4';
|
||||
upIcon.setAttribute('aria-hidden', 'true');
|
||||
up.appendChild(upIcon);
|
||||
up.appendChild(document.createTextNode(' ..'));
|
||||
up.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadDir(data.parent);
|
||||
});
|
||||
list.appendChild(up);
|
||||
}
|
||||
|
||||
(data.dirs || []).forEach(function(dir) {
|
||||
var item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'list-group-item list-group-item-action py-1';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-folder';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(icon);
|
||||
item.appendChild(document.createTextNode(' ' + dir.name));
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
loadDir(dir.path);
|
||||
});
|
||||
item.addEventListener('dblclick', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
browser.style.display = 'none';
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
tree.appendChild(list);
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'mt-2 p-1';
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = 'Current: ' + (data.current || path);
|
||||
info.appendChild(small);
|
||||
tree.appendChild(info);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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() ?: [];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user