Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db7331a72 | |||
| 32931c1e37 | |||
| c299798542 | |||
| 612dc4acd5 | |||
| cdb54f6a3e | |||
| 6fbc91527e | |||
| 57bfb37be1 | |||
| 3328d7cf19 | |||
| c410c02487 | |||
| 93879c8118 | |||
| e4329c9fc6 | |||
| 0fa58daa12 | |||
| f8591ed15c | |||
| cbc7004d18 | |||
| a33a585b98 | |||
| 2573ba8599 | |||
| f0d506bbb1 | |||
| a26343a76e | |||
| 9990240d2d | |||
| 418db394a4 | |||
| d939d8c9d7 | |||
| 6383e9b111 | |||
| 2395a4eabc | |||
| 1ec8ec8f6d | |||
| 8df630c529 | |||
| 5c8503e79e | |||
| 3a087d7859 | |||
| 58d3b812a7 | |||
| ac3727f22f | |||
| 43a4e552ce | |||
| a532e639ea | |||
| f099ad8fe9 | |||
| dbed0d0da7 | |||
| 617c103055 | |||
| edb202071c | |||
| c5fa755006 | |||
| 92e94ddc17 | |||
| 0f88aa0055 | |||
| 0a4f896f31 | |||
| 8d67dfbc42 | |||
| c2a88a898d | |||
| d5421738b7 | |||
| 1962e39032 | |||
| 658ed77090 | |||
| 3bd103f0af | |||
| 854383a899 | |||
| 43c73d20e8 | |||
| ef31713029 | |||
| 4c1615e337 | |||
| 26a13ef692 | |||
| f55836df0d | |||
| 680e242465 | |||
| e2b12ef313 | |||
| d1ca1d8e48 | |||
| b2f8ae8b1f | |||
| 3d84e8079f | |||
| cb68071155 | |||
| eeba315a0d | |||
| 91bc675ea7 | |||
| 1b042311fb | |||
| b4927163a6 | |||
| 571c0a7143 | |||
| 2d7359cefd | |||
| c828c46a4b | |||
| 38a98e5ea3 | |||
| a5a2f48e7c | |||
| 70f25f6e79 | |||
| 26dd5cf5c2 | |||
| b754542254 | |||
| 3942fa4661 | |||
| 0764697086 | |||
| f7767b76dd | |||
| 37c003a97a | |||
| 3c5460559b | |||
| e433314e42 | |||
| db3beae7d3 | |||
| 734a5326e5 | |||
| 6c48a6a777 | |||
| 4e4e608f89 | |||
| 2c17b07835 | |||
| ea17d81e5e | |||
| 58080a5ad7 | |||
| e8f054ddae | |||
| 21bcf479cc | |||
| 695b9e0be5 | |||
| 8fc7616d4d | |||
| afe766302a | |||
| 1a30dd7b98 | |||
| 446296e0c8 | |||
| 8f7c255c22 | |||
| db328cd7e1 | |||
| 6fe5a003ea | |||
| 728e55c721 | |||
| 2554ae98d0 | |||
| 77a3b5f91e | |||
| 3d640d1fa9 | |||
| 7f89abf809 | |||
| c0b40d5a7e | |||
| 8b367c3983 | |||
| 7a92c14de6 | |||
| b70d7f6b80 | |||
| 09f964b427 | |||
| 0b1db735ce | |||
| 77270cceab | |||
| 291f09223d | |||
| 674fd0f6b9 | |||
| 0d2e4b0c01 | |||
| 64c6ef6d7e | |||
| b2630fbc87 | |||
| 2e7e49fa60 | |||
| 55954ba081 | |||
| 7ecc855e40 | |||
| a4c03d0032 | |||
| 682538e4de | |||
| b2874f32f2 | |||
| b3e7c8ec72 | |||
| 9656a2a92b |
@@ -60,3 +60,4 @@ CODE_OF_CONDUCT.md export-ignore
|
||||
Makefile export-ignore
|
||||
composer.json export-ignore
|
||||
phpstan.neon export-ignore
|
||||
*.yml text eol=lf
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mokoplatform xmlns="https://standards.mokoconsulting.tech/mokoplatform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>MokoSuiteBackup</name>
|
||||
<display-name>Package - MokoSuiteBackup</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
|
||||
<version>01.22.06-dev</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
<platform>joomla</platform>
|
||||
<standards-version>05.00.00</standards-version>
|
||||
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/mokoplatform</standards-source>
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>source/</entry-point>
|
||||
</build>
|
||||
</mokoplatform>
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
@@ -43,19 +43,19 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/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"
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
@@ -21,7 +21,7 @@
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
@@ -51,7 +51,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
@@ -66,25 +66,25 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
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
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rename branch to rc
|
||||
@@ -109,13 +109,47 @@ jobs:
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| 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 ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "RC release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
@@ -149,28 +183,34 @@ jobs:
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
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
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: "Detect platform"
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
@@ -194,22 +234,80 @@ jobs:
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
- name: "Read published version"
|
||||
id: version
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||
VERSION=$(python3 -c "
|
||||
import json, sys, re
|
||||
r = json.load(sys.stdin)
|
||||
name = r.get('name', '')
|
||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||
print(m.group(1) if m else '')
|
||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
|
||||
# Update release body via API
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
@@ -219,7 +317,7 @@ jobs:
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
@@ -227,6 +325,24 @@ jobs:
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
content = open('CHANGELOG.md').read()
|
||||
old = '## [Unreleased]'
|
||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||
content = content.replace(old, new, 1)
|
||||
open('CHANGELOG.md', 'w').write(content)
|
||||
" "$VERSION" "$DATE"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||
git push origin main || true
|
||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
@@ -13,19 +13,6 @@
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
- version/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -45,17 +45,17 @@ jobs:
|
||||
fi
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
|
||||
echo "moko-platform already available on runner — skipping clone"
|
||||
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||
echo "mokocli already available on runner — skipping clone"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -245,10 +245,413 @@ jobs:
|
||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check config.xml and access.xml for components
|
||||
run: |
|
||||
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find all component manifests (XML with type="component")
|
||||
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$COMP_MANIFESTS" ]; then
|
||||
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $COMP_MANIFESTS; do
|
||||
COMP_DIR=$(dirname "$MANIFEST")
|
||||
COMP_NAME=$(basename "$COMP_DIR")
|
||||
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check access.xml exists
|
||||
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
for ACTION in core.admin core.manage; do
|
||||
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check config.xml exists
|
||||
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$CONFIG_FILE" ]; then
|
||||
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SQL schema validation
|
||||
run: |
|
||||
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find SQL files in source/htdocs
|
||||
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$SQL_FILES" ]; then
|
||||
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $SQL_FILES; do
|
||||
# Basic syntax check: balanced parentheses, no empty files
|
||||
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||
if [ "$SIZE" -eq 0 ]; then
|
||||
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for common SQL errors
|
||||
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
|
||||
# Check update SQL files follow version numbering pattern
|
||||
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -n "$UPDATE_DIR" ]; then
|
||||
BAD_NAMES=0
|
||||
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||
[ ! -f "$UFILE" ] && continue
|
||||
BASENAME=$(basename "$UFILE" .sql)
|
||||
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||
BAD_NAMES=$((BAD_NAMES + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||
ERRORS=$((ERRORS + BAD_NAMES))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Manifest file references check
|
||||
run: |
|
||||
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <filename> references
|
||||
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILENAMES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <folder> references
|
||||
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Form XML validation
|
||||
run: |
|
||||
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$FORM_FILES" ]; then
|
||||
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
for FILE in $FORM_FILES; do
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
# Check for valid Joomla form structure
|
||||
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Deprecated Joomla API check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||
PATTERNS=(
|
||||
'JFactory::'
|
||||
'JText::'
|
||||
'JHtml::'
|
||||
'JRoute::'
|
||||
'JUri::'
|
||||
'JLog::'
|
||||
'JTable::'
|
||||
'JInput'
|
||||
'CMSFactory::\$application'
|
||||
'JApplicationCms'
|
||||
)
|
||||
|
||||
for PATTERN in "${PATTERNS[@]}"; do
|
||||
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
COUNT=$(echo "$HITS" | wc -l)
|
||||
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Template output escaping check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$TMPL_FILES" ]; then
|
||||
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $TMPL_FILES; do
|
||||
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$UNESCAPED" ]; then
|
||||
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
|
||||
# Check for echo without escaping in template context
|
||||
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$RAW_ECHO" ]; then
|
||||
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Namespace consistency check
|
||||
run: |
|
||||
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find component/plugin manifests with <namespace> tags
|
||||
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $MANIFESTS; do
|
||||
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$NS_PATH" ] && continue
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check PHP files have matching namespace
|
||||
while IFS= read -r -d '' PHP_FILE; do
|
||||
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_NS" ] && continue
|
||||
|
||||
# Namespace should start with the manifest namespace path
|
||||
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SPDX license header check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL=0
|
||||
while IFS= read -r -d '' FILE; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Service provider check
|
||||
run: |
|
||||
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$PROVIDERS" ]; then
|
||||
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for FILE in $PROVIDERS; do
|
||||
# Must return a ServiceProviderInterface
|
||||
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Must have return statement
|
||||
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
release-readiness:
|
||||
name: Release Readiness Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -25,10 +25,6 @@
|
||||
name: "Universal: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokoplatform.Automation
|
||||
# VERSION: 01.22.06
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.28.01
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
@@ -96,6 +96,32 @@ jobs:
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||
gitleaks:
|
||||
name: Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
GITLEAKS_VERSION="8.21.2"
|
||||
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin gitleaks
|
||||
|
||||
- name: Scan PR commits for secrets
|
||||
run: |
|
||||
if gitleaks detect --source . --verbose \
|
||||
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
||||
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::Potential secrets detected in PR commits"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||
|
||||
name: "Joomla: Metadata Validation"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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:
|
||||
validate-metadata:
|
||||
name: "Validate Joomla Metadata"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Validate metadata against Joomla manifest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||
--path . \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--api-base "${GITEA_URL}/api/v1" \
|
||||
--ci
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
@@ -60,25 +60,25 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli 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/cli/manifest_element.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
|
||||
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
@@ -88,8 +88,20 @@ jobs:
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
@@ -166,6 +178,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -176,6 +189,7 @@ jobs:
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -212,6 +226,7 @@ jobs:
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
@@ -225,6 +240,7 @@ jobs:
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoPlatform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
@@ -33,7 +33,8 @@ on:
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||
# VERSION: 01.01.00
|
||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync workflows to live repos
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
id: platform
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
case "$REPO" in
|
||||
Template-Joomla) PLATFORM="joomla" ;;
|
||||
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||
Template-Go) PLATFORM="go" ;;
|
||||
Template-MCP) PLATFORM="mcp" ;;
|
||||
Template-Generic) PLATFORM="" ;;
|
||||
*) PLATFORM="" ;;
|
||||
esac
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform: ${PLATFORM:-all}"
|
||||
|
||||
- name: Clone mokocli
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
|
||||
- name: Run workflow sync
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||
ARGS="${ARGS} --phase repos"
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||
fi
|
||||
|
||||
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||
+6
-30
@@ -1,38 +1,14 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.21.00] --- 2026-06-16
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
### Fixed
|
||||
- Admin submenu items (Dashboard, Backups, Profiles) not created on install — `<submenu>` block in manifest was empty
|
||||
- Submenu items not created on update — added `ensureSubmenuItems()` using Joomla's `MenuTable` API with proper nested set positioning
|
||||
- Submenu icons not rendering in Joomla 6 — set `menu_icon` param for level 2+ items (Atum only renders `img` column icons for level 1)
|
||||
- CSS selector `#menu` → `.main-nav` for icon injection (Joomla 6 uses dynamic `id="menu{moduleId}"`)
|
||||
- Use `margin-inline-end` instead of `margin-right` for RTL layout support
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
## [01.08.00] --- 2026-06-07
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
## [01.07.00] --- 2026-06-07
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
## [01.06.00] --- 2026-06-07
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
|
||||
## [01.05.00] --- 2026-06-07
|
||||
|
||||
### Added
|
||||
- Dashboard submenu entry as default landing page with `class:home` icon
|
||||
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokosuitebackup/backups` at runtime
|
||||
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
|
||||
- `checkDir` AJAX endpoint for real-time directory permission checking
|
||||
- Web-accessible warning badge on backup download buttons when archive is inside web root
|
||||
- Inline security warning in FolderPicker when default directory is selected
|
||||
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
|
||||
- Font Awesome 6 submenu icons via CSS injection in `MokoSuiteBackupComponent::boot()`
|
||||
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
|
||||
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
|
||||
|
||||
### Changed
|
||||
- Profile `backup_dir` default changed from literal path to `[DEFAULT_DIR]` placeholder
|
||||
- Backup engine fallback directory changed from hardcoded path to `[DEFAULT_DIR]`
|
||||
- `isUsingDefaultBackupDir()` now matches `[DEFAULT_DIR]` placeholder in addition to literal path and empty values
|
||||
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# Makefile for Joomla Extensions
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# MokoSuiteBackup — 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
|
||||
# ==============================================================================
|
||||
|
||||
EXTENSION_NAME := mokosuitebackup
|
||||
EXTENSION_TYPE := package
|
||||
|
||||
SRC_DIR := source
|
||||
|
||||
# Gitea
|
||||
GITEA_URL := https://git.mokoconsulting.tech
|
||||
GITEA_ORG := MokoConsulting
|
||||
GITEA_REPO := MokoSuiteBackup
|
||||
|
||||
# Tools
|
||||
PHP := php
|
||||
COMPOSER := composer
|
||||
PHPCS := vendor/bin/phpcs
|
||||
|
||||
# Coding Standards
|
||||
PHPCS_STANDARD := Joomla
|
||||
|
||||
# Colors for output
|
||||
COLOR_RESET := \033[0m
|
||||
COLOR_GREEN := \033[32m
|
||||
COLOR_YELLOW := \033[33m
|
||||
COLOR_BLUE := \033[34m
|
||||
COLOR_RED := \033[31m
|
||||
|
||||
# ==============================================================================
|
||||
# TARGETS
|
||||
# ==============================================================================
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)║ MokoSuiteBackup Makefile ║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@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 ""
|
||||
|
||||
# -- Validation ----------------------------------------------------------------
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run PHP syntax check on all source files
|
||||
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
|
||||
@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 $(SRC_DIR); \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: validate
|
||||
validate: lint ## Run all local validation checks
|
||||
@echo "$(COLOR_GREEN)✓ Validation passed$(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 "$(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 "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
# -- Release (CI workflow dispatch) --------------------------------------------
|
||||
|
||||
.PHONY: release
|
||||
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 from package manifest
|
||||
@VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokosuitebackup.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 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.22.06 -->
|
||||
<!-- VERSION: 01.28.01 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage plg_webservices_mokosuitebackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoSuiteBackup
|
||||
* @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
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_WEBSERVICES_MOKOJOOMBACKUP_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\WebServices\MokoSuiteBackup</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokosuitebackup">mokosuitebackup.php</filename>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -11,5 +11,6 @@
|
||||
<action name="mokosuitebackup.backup.run" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN" />
|
||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -121,11 +121,27 @@ class BackupsController extends ApiController
|
||||
|
||||
$data = [];
|
||||
|
||||
// Strip sensitive credentials before serialization
|
||||
$sensitiveFields = [
|
||||
'ftp_password', 'ftp_username',
|
||||
's3_access_key', 's3_secret_key',
|
||||
'gdrive_client_secret', 'gdrive_refresh_token',
|
||||
'encryption_password', 'ntfy_token',
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safe = clone $item;
|
||||
|
||||
foreach ($sensitiveFields as $field) {
|
||||
if (isset($safe->$field) && $safe->$field !== '') {
|
||||
$safe->$field = '***';
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'type' => 'profiles',
|
||||
'id' => $item->id,
|
||||
'attributes' => $item,
|
||||
'id' => $safe->id,
|
||||
'attributes' => $safe,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokosuitebackup/backups"
|
||||
default="[DEFAULT_DIR]"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
@@ -21,10 +21,10 @@
|
||||
type="sql"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
|
||||
query="SELECT id AS value, title AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
|
||||
query="SELECT id AS value, CONCAT(title, ' (#', id, ')') AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
|
||||
default="1"
|
||||
>
|
||||
<option value="1">Default Backup Profile</option>
|
||||
<option value="1">Default Backup Profile (#1)</option>
|
||||
</field>
|
||||
<field
|
||||
name="show_update_notice"
|
||||
@@ -42,12 +42,13 @@
|
||||
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
||||
<field
|
||||
name="webcron_secret"
|
||||
type="text"
|
||||
type="WebcronSecret"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
maxlength="64"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="webcron_enabled"
|
||||
@@ -62,12 +63,12 @@
|
||||
</field>
|
||||
<field
|
||||
name="webcron_ip_whitelist"
|
||||
type="text"
|
||||
type="IpWhitelist"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
hint="Leave blank to allow any IP"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
|
||||
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
|
||||
</field>
|
||||
<field
|
||||
name="backup_type"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="">COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL</option>
|
||||
<option value="full">COM_MOKOJOOMBACKUP_TYPE_FULL</option>
|
||||
<option value="database">COM_MOKOJOOMBACKUP_TYPE_DATABASE</option>
|
||||
<option value="files">COM_MOKOJOOMBACKUP_TYPE_FILES</option>
|
||||
<option value="differential">COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL</option>
|
||||
</field>
|
||||
</fields>
|
||||
|
||||
<fields name="list">
|
||||
|
||||
@@ -167,6 +167,7 @@ COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
|
||||
COM_MOKOJOOMBACKUP_FILTER_SEARCH="Search"
|
||||
COM_MOKOJOOMBACKUP_FILTER_STATUS="Status"
|
||||
COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL="- Select Status -"
|
||||
COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL="- Select Type -"
|
||||
|
||||
; Tabs and fieldsets
|
||||
COM_MOKOJOOMBACKUP_TAB_GENERAL="General"
|
||||
@@ -299,6 +300,64 @@ COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store bac
|
||||
|
||||
COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
|
||||
|
||||
; Restore modal
|
||||
COM_MOKOJOOMBACKUP_RESTORE_FILES="Restore files"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_DATABASE="Restore database"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG="Preserve current configuration.php"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC="Keep your current database credentials and site paths. Recommended unless you know the backup has the correct credentials."
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER="Leave blank if archive is not encrypted"
|
||||
|
||||
; Snapshots
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS="Content Snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE="Content Snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION="Table of content snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE="No snapshots found. Click 'Create Snapshot' to save a snapshot of your content."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE="Create Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE="Restore Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES="Select content to snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER="e.g. Before redesign, Pre-migration"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES="Content Types"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES="Articles"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC="All articles, frontpage settings, workflow state, and tags"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES="Categories"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC="Content categories (com_content)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES="Modules"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC="All modules and their menu assignments"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES="Please select at least one content type to snapshot."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD="No snapshot selected."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE="Restore Mode"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE="Replace (clean)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC="Remove all existing content of the selected types and replace with snapshot data. This gives you an exact copy of the snapshot state."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE="Merge (upsert)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC="Update existing items by ID and insert missing ones. Content added after the snapshot is preserved."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES="Types to restore"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING="Replace mode will delete all current content of the selected types before restoring from the snapshot. This cannot be undone."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||
|
||||
; Snapshot ACL
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
||||
|
||||
; Webcron secret field
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_GENERATE="Generate"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE="No secret set — webcron is disabled."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT="Too short — minimum %d characters required."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK="Weak — avoid common words like 'password', 'admin', 'secret'."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK="Acceptable — consider making it longer for better security."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG="Strong secret word."
|
||||
|
||||
; IP whitelist field
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP="Your current IP"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP="Add my IP"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED="Included"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS="IP Address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE="Remove"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger webcron (if secret is correct)."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -45,6 +45,9 @@
|
||||
<menu link="option=com_mokosuitebackup&view=backups"
|
||||
img="class:database"
|
||||
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=snapshots"
|
||||
img="class:camera"
|
||||
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=profiles"
|
||||
img="class:cog"
|
||||
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
|
||||
|
||||
@@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
|
||||
KEY `idx_backupstart` (`backupstart`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
|
||||
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
|
||||
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
|
||||
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
|
||||
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
|
||||
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
|
||||
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -49,6 +49,13 @@ class BackupsController extends AdminController
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'backend');
|
||||
|
||||
// Surface preflight warnings as Joomla messages
|
||||
if (!empty($result['warnings'])) {
|
||||
foreach ($result['warnings'] as $warning) {
|
||||
$this->app->enqueueMessage($warning, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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\MokoSuiteBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
||||
|
||||
class SnapshotsController extends AdminController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOJOOMBACKUP_SNAPSHOTS';
|
||||
|
||||
public function getModel($name = 'Snapshot', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new content snapshot.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contentTypes = $this->input->get('content_types', [], 'array');
|
||||
$description = $this->input->getString('description', '');
|
||||
|
||||
if (empty($contentTypes)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SnapshotEngine();
|
||||
$result = $engine->create($contentTypes, $description);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a content snapshot.
|
||||
*/
|
||||
public function restore(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
$mode = $this->input->getCmd('restore_mode', 'replace');
|
||||
$contentTypes = $this->input->get('restore_types', [], 'array');
|
||||
|
||||
// Enforce valid restore mode at controller boundary
|
||||
if (!in_array($mode, ['replace', 'merge'], true)) {
|
||||
$mode = 'replace';
|
||||
}
|
||||
|
||||
if (!$id) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SnapshotRestoreEngine();
|
||||
$result = $engine->restore($id, $mode, $contentTypes);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete snapshot records and their data files.
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cid = $this->input->get('cid', [], 'array');
|
||||
|
||||
if (empty($cid)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$deleted = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($cid as $id) {
|
||||
$id = (int) $id;
|
||||
|
||||
try {
|
||||
// Load record to get file path
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('data_file'))
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$dataFile = $db->loadResult();
|
||||
|
||||
// Delete data file
|
||||
if ($dataFile && is_file($dataFile)) {
|
||||
if (!unlink($dataFile)) {
|
||||
error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $dataFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
$deleted++;
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: Failed to delete snapshot ' . $id . ': ' . $e->getMessage());
|
||||
$errors[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->setMessage(
|
||||
Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)
|
||||
. ' ' . Text::sprintf('COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS', implode(', ', $errors)),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
$this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted));
|
||||
}
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
}
|
||||
@@ -360,16 +360,12 @@ class AkeebaImporter
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
// Parse as JSON only — unserialize is an object injection risk
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
// Try unserialize (older Akeeba versions)
|
||||
$data = @unserialize($raw);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $result;
|
||||
}
|
||||
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Extract directory exclusions
|
||||
|
||||
@@ -32,16 +32,21 @@ class BackupEngine
|
||||
*/
|
||||
public function run(int $profileId, string $description, string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
// Override PHP limits for long-running backup operations
|
||||
$this->overridePhpLimits();
|
||||
|
||||
// Verify required extensions
|
||||
$extCheck = $this->checkRequiredExtensions();
|
||||
|
||||
if ($extCheck !== true) {
|
||||
return ['success' => false, 'message' => $extCheck];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -53,7 +58,12 @@ class BackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Log any preflight warnings
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$this->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
// Read settings directly from profile columns
|
||||
@@ -68,13 +78,14 @@ class BackupEngine
|
||||
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (!BackupDirectory::ensureReady($this->backupDir)) {
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiveName = '';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
@@ -120,12 +131,15 @@ class BackupEngine
|
||||
$tablesCount = 0;
|
||||
|
||||
// Step 1: Database dump (unless files-only)
|
||||
// Streams to a temp file to avoid loading the entire dump into RAM
|
||||
$sqlTempFile = '';
|
||||
|
||||
if ($profile->backup_type !== 'files') {
|
||||
$this->log('Starting database dump...');
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sqlDump = $dumper->dump();
|
||||
$archiver->addFromString('database.sql', $sqlDump);
|
||||
$dbSize = strlen($sqlDump);
|
||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
||||
}
|
||||
@@ -193,6 +207,11 @@ class BackupEngine
|
||||
|
||||
$archiver->close();
|
||||
|
||||
// Clean up temp SQL file (no longer needed after archive is closed)
|
||||
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
||||
@unlink($sqlTempFile);
|
||||
}
|
||||
|
||||
// Step 1.5: Apply AES-256 encryption (if configured)
|
||||
$encryptionPassword = $profile->encryption_password ?? '';
|
||||
|
||||
@@ -300,20 +319,36 @@ class BackupEngine
|
||||
'success' => true,
|
||||
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
|
||||
'record_id' => $recordId,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Clean up temp SQL file on failure
|
||||
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
||||
@unlink($sqlTempFile);
|
||||
}
|
||||
|
||||
// If encryption was intended and failed, remove the plaintext archive
|
||||
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Plaintext archive removed after encryption failure');
|
||||
}
|
||||
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
'status' => 'fail',
|
||||
'description' => $description ?: '',
|
||||
'backup_type' => $profile->backup_type ?? 'full',
|
||||
'origin' => $origin,
|
||||
'archivename' => $archiveName,
|
||||
'backupstart' => $now ?? date('Y-m-d H:i:s'),
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'log' => implode("\n", $this->log),
|
||||
'id' => $recordId,
|
||||
'status' => 'fail',
|
||||
'description' => $description ?: '',
|
||||
'backup_type' => $profile->backup_type ?? 'full',
|
||||
'origin' => $origin,
|
||||
'archivename' => $archiveName,
|
||||
'backupstart' => $now ?? date('Y-m-d H:i:s'),
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'total_size' => 0,
|
||||
'files_count' => 0,
|
||||
'tables_count' => 0,
|
||||
'remote_filename' => '',
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
@@ -324,7 +359,7 @@ class BackupEngine
|
||||
// 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];
|
||||
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,35 +414,6 @@ class BackupEngine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify required PHP extensions are loaded.
|
||||
*
|
||||
* @return true|string True if all ok, or error message string
|
||||
*/
|
||||
private function checkRequiredExtensions(): true|string
|
||||
{
|
||||
$required = [
|
||||
'zip' => 'ext-zip (required for archive creation)',
|
||||
'pdo' => 'ext-pdo (required for database operations)',
|
||||
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
|
||||
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $ext => $label) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$missing[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
return 'Missing PHP extensions: ' . implode(', ', $missing);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate archiver based on the archive format.
|
||||
*/
|
||||
@@ -416,7 +422,7 @@ class BackupEngine
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
default => new ZipArchiver(),
|
||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,6 +493,7 @@ class BackupEngine
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
if ($name === false) {
|
||||
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ class DatabaseDumper
|
||||
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
|
||||
$output[] = '-- Server: ' . $db->getServerType();
|
||||
$output[] = '-- Database: ' . $db->getName();
|
||||
$output[] = '-- Prefix: ' . $prefix;
|
||||
$output[] = '-- Original Prefix: ' . $prefix;
|
||||
$output[] = '-- Abstract Prefix: #__';
|
||||
$output[] = '-- Note: Table names use #__ placeholder. Replace with your prefix on restore.';
|
||||
$output[] = '';
|
||||
$output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";';
|
||||
$output[] = 'SET time_zone = "+00:00";';
|
||||
@@ -90,7 +92,7 @@ class DatabaseDumper
|
||||
$this->tablesCount++;
|
||||
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
$output[] = '-- Table: ' . $abstractName;
|
||||
|
||||
if ($skipData) {
|
||||
$output[] = '-- (data excluded)';
|
||||
@@ -112,8 +114,11 @@ class DatabaseDumper
|
||||
continue;
|
||||
}
|
||||
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
// Replace all occurrences of the live prefix with #__ in CREATE TABLE
|
||||
// output — covers the table itself and FK REFERENCES to other tables
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
|
||||
$output[] = $createSql . ';';
|
||||
$output[] = '';
|
||||
}
|
||||
|
||||
@@ -160,7 +165,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
$output[] = 'INSERT INTO ' . $db->quoteName($table)
|
||||
$output[] = 'INSERT INTO `' . $abstractName . '`'
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ');';
|
||||
}
|
||||
@@ -214,6 +219,138 @@ class DatabaseDumper
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all database tables directly to a file, streaming row by row.
|
||||
* Avoids loading the entire dump into RAM.
|
||||
*
|
||||
* @param string $filePath Absolute path to write the SQL file
|
||||
*
|
||||
* @return int Size of the dump file in bytes
|
||||
*/
|
||||
public function dumpToFile(string $filePath): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$fp = fopen($filePath, 'w');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath);
|
||||
}
|
||||
|
||||
fwrite($fp, "-- MokoSuiteBackup Database Dump\n");
|
||||
fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n");
|
||||
fwrite($fp, "-- Server: " . $db->getServerType() . "\n");
|
||||
fwrite($fp, "-- Database: " . $db->getName() . "\n");
|
||||
fwrite($fp, "-- Original Prefix: " . $prefix . "\n");
|
||||
fwrite($fp, "-- Abstract Prefix: #__\n");
|
||||
fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n");
|
||||
fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n");
|
||||
fwrite($fp, "SET time_zone = \"+00:00\";\n\n");
|
||||
|
||||
// Get all tables with the site prefix
|
||||
$tables = $db->getTableList();
|
||||
$siteTables = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (str_starts_with($table, $prefix)) {
|
||||
$siteTables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($siteTables as $table) {
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
if ($this->isExcludedBoth($abstractName, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipData = $this->isExcludedDataOnly($abstractName, $table);
|
||||
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
|
||||
|
||||
$this->tablesCount++;
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n");
|
||||
fwrite($fp, "-- Table: " . $abstractName . "\n");
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "-- (data excluded)\n");
|
||||
}
|
||||
|
||||
if ($skipStructure) {
|
||||
fwrite($fp, "-- (structure excluded)\n");
|
||||
}
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n\n");
|
||||
|
||||
if (!$skipStructure) {
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
|
||||
fwrite($fp, $createSql . ";\n\n");
|
||||
}
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$rowCount = (int) $db->loadResult();
|
||||
|
||||
if ($rowCount === 0) {
|
||||
fwrite($fp, "-- (empty table)\n\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunkSize = 500;
|
||||
|
||||
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table)),
|
||||
$offset,
|
||||
$chunkSize
|
||||
);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
if (empty($rows)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($value === null) {
|
||||
$values[] = 'NULL';
|
||||
} else {
|
||||
$values[] = $db->quote($value);
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
fwrite($fp, 'INSERT INTO `' . $abstractName . '`'
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ");\n");
|
||||
}
|
||||
}
|
||||
|
||||
fwrite($fp, "\n");
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
return filesize($filePath) ?: 0;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -87,11 +87,8 @@ class DatabaseImporter
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace the prefix from the dump with the current site prefix.
|
||||
// The dump uses real table names (with the original prefix), but
|
||||
// if restoring to a site with a different prefix we need to handle it.
|
||||
// Our DatabaseDumper uses real names, so no replacement needed
|
||||
// for same-site restores.
|
||||
// Replace abstract #__ prefix with the current site's prefix
|
||||
$statement = str_replace('#__', $prefix, $statement);
|
||||
|
||||
try {
|
||||
$db->setQuery($statement);
|
||||
@@ -110,6 +107,8 @@ class DatabaseImporter
|
||||
$remaining = trim($currentStatement);
|
||||
|
||||
if (!empty($remaining)) {
|
||||
$remaining = str_replace('#__', $prefix, $remaining);
|
||||
|
||||
try {
|
||||
$db->setQuery($remaining);
|
||||
$db->execute();
|
||||
|
||||
@@ -206,6 +206,11 @@ class JpaUnarchiver
|
||||
}
|
||||
}
|
||||
|
||||
// Path traversal protection: reject absolute paths and directory traversal
|
||||
if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) {
|
||||
return; // skip malicious entry
|
||||
}
|
||||
|
||||
// Is this a directory?
|
||||
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
|
||||
$dirPath = $this->outputDir . '/' . $path;
|
||||
@@ -228,6 +233,24 @@ class JpaUnarchiver
|
||||
|
||||
// Write file
|
||||
$fullPath = $this->outputDir . '/' . $path;
|
||||
|
||||
// Verify resolved path stays within output directory
|
||||
$realOutput = realpath($this->outputDir);
|
||||
|
||||
if ($realOutput !== false) {
|
||||
$parentDir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
mkdir($parentDir, 0755, true);
|
||||
}
|
||||
|
||||
$realDest = realpath($parentDir);
|
||||
|
||||
if ($realDest === false || !str_starts_with($realDest, $realOutput)) {
|
||||
return; // path escapes staging directory
|
||||
}
|
||||
}
|
||||
|
||||
$parentDir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
|
||||
@@ -109,6 +109,56 @@ if (empty($_SESSION['restore_token'])) {
|
||||
|
||||
$token = $_SESSION['restore_token'];
|
||||
|
||||
// ── Security Verification ───────────────────────────────────────────
|
||||
// Write a security file to the web root with a random code.
|
||||
// The user must read the code from the file and enter it in the browser
|
||||
// to prove they have filesystem access before any restore actions are allowed.
|
||||
$securityFile = RESTORE_DIR . '/.mokorestore-security.php';
|
||||
$securityCode = $_SESSION['security_code'] ?? '';
|
||||
|
||||
if (empty($securityCode)) {
|
||||
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||
$_SESSION['security_code'] = $securityCode;
|
||||
$_SESSION['security_verified'] = false;
|
||||
|
||||
// Write security file with the code
|
||||
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
|
||||
. "MokoRestore Security Verification\n"
|
||||
. "==================================\n"
|
||||
. "Code: " . $securityCode . "\n"
|
||||
. "Enter this code in the MokoRestore browser interface to proceed.\n"
|
||||
. "This file will be deleted automatically after verification.\n";
|
||||
if (file_put_contents($securityFile, $securityContent) === false) {
|
||||
// Cannot write security file — skip verification to avoid locking user out
|
||||
$_SESSION['security_verified'] = true;
|
||||
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle security code verification via POST
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_security') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$inputCode = strtoupper(trim($_POST['security_code'] ?? ''));
|
||||
|
||||
if ($inputCode === $securityCode) {
|
||||
$_SESSION['security_verified'] = true;
|
||||
|
||||
// Delete the security file
|
||||
if (is_file($securityFile)) {
|
||||
@unlink($securityFile);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Security verified']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// Block all other actions until security is verified
|
||||
$securityVerified = !empty($_SESSION['security_verified']);
|
||||
|
||||
// ── AJAX Handler ────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -118,6 +168,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$securityVerified) {
|
||||
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@set_time_limit(0);
|
||||
@ini_set('max_execution_time', '0');
|
||||
@ini_set('memory_limit', '512M');
|
||||
@@ -248,6 +303,20 @@ function actionExtract(array $data): array
|
||||
$zip->setPassword($password);
|
||||
}
|
||||
|
||||
// Validate all entries before extraction (path traversal protection)
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
|
||||
if ($entryName === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) {
|
||||
$zip->close();
|
||||
throw new RuntimeException('Archive contains unsafe path: ' . $entryName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$zip->extractTo(RESTORE_DIR)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(
|
||||
@@ -348,7 +417,12 @@ function actionDatabase(array $data): array
|
||||
$pdo->exec("SET time_zone = '+00:00'");
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
$sql = file_get_contents($sqlFile);
|
||||
$sql = file_get_contents($sqlFile);
|
||||
$prefix = getValidatedPrefix($data);
|
||||
|
||||
// Replace abstract #__ prefix with the user's target prefix
|
||||
$sql = str_replace('#__', $prefix, $sql);
|
||||
|
||||
$parts = explode(";\n", $sql);
|
||||
$statements = 0;
|
||||
$errors = 0;
|
||||
@@ -675,7 +749,7 @@ HTACCESS;
|
||||
|
||||
function getValidatedPrefix(array $data): string
|
||||
{
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$prefix = trim($data['db_prefix'] ?? 'moko_');
|
||||
|
||||
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
|
||||
throw new RuntimeException('Invalid table prefix format');
|
||||
@@ -710,7 +784,7 @@ function actionListAdmins(array $data): array
|
||||
function actionResetAdmin(array $data): array
|
||||
{
|
||||
$pdo = getDbConnection($data);
|
||||
$prefix = $data['db_prefix'] ?? 'moko_';
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$userId = (int) ($data['admin_id'] ?? 0);
|
||||
$password = $data['new_password'] ?? '';
|
||||
|
||||
@@ -981,8 +1055,33 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Security Verification -->
|
||||
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
|
||||
<h2>Security Verification</h2>
|
||||
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p>
|
||||
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
|
||||
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
|
||||
<span style="font-size:1.1rem">🔒</span> How to find the code
|
||||
</div>
|
||||
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
|
||||
<li>Connect to your server via FTP, SSH, or file manager</li>
|
||||
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li>
|
||||
<li>Copy the 8-character code and enter it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="mr-field">
|
||||
<label>Security Code</label>
|
||||
<input type="text" id="securityCode" placeholder="e.g. A1B2C3D4" maxlength="8" style="text-transform:uppercase;letter-spacing:0.2em;font-family:monospace;font-size:1.1rem;text-align:center">
|
||||
</div>
|
||||
<div class="mr-status" id="securityStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<span></span>
|
||||
<button class="mr-btn mr-btn-primary" id="btnVerify" onclick="verifySecurity()">Verify & Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Pre-flight Checks -->
|
||||
<div class="mr-panel visible" id="panel1">
|
||||
<div class="mr-panel <?php echo $securityVerified ? 'visible' : ''; ?>" id="panel1">
|
||||
<h2>Pre-Installation Checks</h2>
|
||||
<p class="mr-desc">Verify your server meets the requirements for Joomla and MokoRestore.</p>
|
||||
<ul class="mr-checks" id="checkList"></ul>
|
||||
@@ -1223,6 +1322,35 @@ function setBtnLoading(btn, loading) {
|
||||
}
|
||||
|
||||
// Step 1
|
||||
async function verifySecurity() {
|
||||
const btn = document.getElementById('btnVerify');
|
||||
setBtnLoading(btn, true);
|
||||
const code = document.getElementById('securityCode').value.trim();
|
||||
|
||||
if (!code) {
|
||||
setStatus('securityStatus', 'Please enter the security code', 'error');
|
||||
setBtnLoading(btn, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('action', 'verify_security');
|
||||
form.append('security_code', code);
|
||||
form.append('token', TOKEN);
|
||||
|
||||
const resp = await fetch('', { method: 'POST', body: form });
|
||||
const r = await resp.json();
|
||||
setBtnLoading(btn, false);
|
||||
|
||||
if (r.success) {
|
||||
setStatus('securityStatus', 'Verified!', 'success');
|
||||
document.getElementById('panel0').classList.remove('visible');
|
||||
document.getElementById('panel1').classList.add('visible');
|
||||
} else {
|
||||
setStatus('securityStatus', r.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreflight() {
|
||||
const btn = document.getElementById('btnCheck');
|
||||
setBtnLoading(btn, true);
|
||||
|
||||
@@ -169,6 +169,12 @@ class NotificationSender
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
error_log('MokoSuiteBackup: ntfy notifications require ext-curl');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Factory::getApplication()->getConfig();
|
||||
$siteName = $config->get('sitename', 'Joomla Site');
|
||||
@@ -219,7 +225,7 @@ class NotificationSender
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . $response);
|
||||
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Pre-flight validation for backup operations.
|
||||
*
|
||||
* Runs before any backup record is created, catching problems early
|
||||
* with clear messages instead of failing mid-backup. Returns a result
|
||||
* with errors (blockers) and warnings (informational).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class PreflightCheck
|
||||
{
|
||||
/** @var string[] Fatal issues that prevent backup from starting */
|
||||
private array $errors = [];
|
||||
|
||||
/** @var string[] Non-fatal issues the user should know about */
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Run all pre-flight checks for a backup profile.
|
||||
*
|
||||
* @param int $profileId Profile to validate
|
||||
*
|
||||
* @return array{pass: bool, errors: string[], warnings: string[]}
|
||||
*/
|
||||
public function run(int $profileId): array
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
// Load profile
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = 'Cannot load profile: ' . $e->getMessage();
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
if (!$profile) {
|
||||
$this->errors[] = 'Profile not found: #' . $profileId;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
if (!$profile->published) {
|
||||
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
$this->checkPhpExtensions($profile);
|
||||
$this->checkBackupDirectory($profile);
|
||||
$this->checkDiskSpace($profile, $db);
|
||||
$this->checkRunningBackup($profile, $db);
|
||||
$this->checkExcludedTables($profile, $db);
|
||||
$this->checkRemoteCredentials($profile);
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that required PHP extensions are loaded.
|
||||
*/
|
||||
private function checkPhpExtensions(object $profile): void
|
||||
{
|
||||
$required = ['pdo', 'pdo_mysql', 'mbstring'];
|
||||
|
||||
// ZIP is required unless using tar.gz
|
||||
$format = $profile->archive_format ?? 'zip';
|
||||
|
||||
if ($format === 'zip') {
|
||||
$required[] = 'zip';
|
||||
}
|
||||
|
||||
foreach ($required as $ext) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
|
||||
}
|
||||
}
|
||||
|
||||
// curl is only needed for remote upload and ntfy notifications
|
||||
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|
||||
|| !empty($profile->ntfy_topic);
|
||||
|
||||
if ($needsCurl && !extension_loaded('curl')) {
|
||||
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the backup directory exists and is writable.
|
||||
*/
|
||||
private function checkBackupDirectory(object $profile): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
|
||||
// Resolve placeholders using a temporary resolver
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
|
||||
$this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir
|
||||
. ' — directory cannot be validated until backup runs';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($resolvedDir)) {
|
||||
// Try to create it
|
||||
if (!@mkdir($resolvedDir, 0755, true)) {
|
||||
$lastError = error_get_last();
|
||||
$reason = $lastError['message'] ?? 'unknown reason';
|
||||
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
|
||||
. ' (' . $reason . ')';
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($resolvedDir)) {
|
||||
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check available disk space against the last backup size + 20% buffer.
|
||||
* Skipped if no previous backup exists for this profile.
|
||||
*/
|
||||
private function checkDiskSpace(object $profile, object $db): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find last successful backup size for this profile
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('total_size'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('total_size') . ' > 0')
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$lastSize = (int) $db->loadResult();
|
||||
|
||||
if ($lastSize === 0) {
|
||||
// No previous backup — skip disk space check
|
||||
return;
|
||||
}
|
||||
|
||||
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
|
||||
$freeBytes = @disk_free_space($resolvedDir);
|
||||
|
||||
if ($freeBytes === false) {
|
||||
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($freeBytes < $requiredBytes) {
|
||||
$freeMB = number_format($freeBytes / 1048576, 1);
|
||||
$neededMB = number_format($requiredBytes / 1048576, 1);
|
||||
|
||||
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
|
||||
. ' (based on last backup + 20% buffer)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if another backup is already running for this profile.
|
||||
*/
|
||||
private function checkRunningBackup(object $profile, object $db): void
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
|
||||
$db->setQuery($query);
|
||||
$running = (int) $db->loadResult();
|
||||
|
||||
if ($running > 0) {
|
||||
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
|
||||
. ' — wait for it to finish or delete the stale record';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that excluded tables actually exist in the database.
|
||||
* Missing tables are warnings, not errors — the profile may have
|
||||
* been copied from another site or a table may have been removed.
|
||||
*/
|
||||
private function checkExcludedTables(object $profile, object $db): void
|
||||
{
|
||||
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
if (empty($excludeRaw)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$allTables = array_flip($db->getTableList());
|
||||
|
||||
foreach ($excludeRaw as $entry) {
|
||||
// Strip :data-only / :structure-only suffixes
|
||||
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
|
||||
|
||||
// Resolve #__ prefix to real prefix
|
||||
$realName = str_replace('#__', $prefix, $tableName);
|
||||
|
||||
if (!isset($allTables[$realName])) {
|
||||
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
|
||||
. ' — it will be silently skipped during backup';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that remote storage credentials are minimally configured.
|
||||
* Does not test the actual connection (too slow for preflight).
|
||||
*/
|
||||
private function checkRemoteCredentials(object $profile): void
|
||||
{
|
||||
$remote = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remote === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($remote) {
|
||||
case 'ftp':
|
||||
if (empty($profile->ftp_host)) {
|
||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->ftp_username)) {
|
||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
if (empty($profile->s3_bucket)) {
|
||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'google_drive':
|
||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->gdrive_refresh_token)) {
|
||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the result array.
|
||||
*/
|
||||
private function result(): array
|
||||
{
|
||||
return [
|
||||
'pass' => empty($this->errors),
|
||||
'errors' => $this->errors,
|
||||
'warnings' => $this->warnings,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -76,8 +76,9 @@ class RestoreEngine
|
||||
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
|
||||
}
|
||||
|
||||
// Create staging directory
|
||||
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $record->tag;
|
||||
// Create staging directory (sanitize tag to prevent path traversal)
|
||||
$safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore');
|
||||
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag;
|
||||
|
||||
if (is_dir($this->stagingDir)) {
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
@@ -190,6 +191,20 @@ class RestoreEngine
|
||||
$this->log('Decryption password set');
|
||||
}
|
||||
|
||||
// Validate all entries before extraction (path traversal protection)
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
|
||||
if ($entryName === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException('Archive contains unsafe path: ' . $entryName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$zip->extractTo($this->stagingDir)) {
|
||||
$zip->close();
|
||||
|
||||
@@ -209,6 +224,18 @@ class RestoreEngine
|
||||
private function extractTarGz(string $archivePath): void
|
||||
{
|
||||
$phar = new \PharData($archivePath);
|
||||
|
||||
// Validate all entries before extraction (path traversal protection)
|
||||
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
||||
$entryName = $entry->getPathname();
|
||||
// PharData paths are prefixed with phar:// — extract the relative part
|
||||
$relative = substr($entryName, strlen('phar://' . $archivePath) + 1);
|
||||
|
||||
if (str_contains($relative, '../') || str_contains($relative, '..\\') || str_starts_with($relative, '/') || str_starts_with($relative, '\\')) {
|
||||
throw new \RuntimeException('Archive contains unsafe path: ' . $relative);
|
||||
}
|
||||
}
|
||||
|
||||
$phar->extractTo($this->stagingDir, null, true);
|
||||
$this->log('Extracted tar.gz archive');
|
||||
}
|
||||
|
||||
@@ -114,19 +114,28 @@ class S3Uploader implements RemoteUploaderInterface
|
||||
*/
|
||||
private function singleUpload(string $localPath, string $objectKey): void
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileContent = file_get_contents($localPath);
|
||||
$contentHash = hash('sha256', $fileContent);
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileSize = filesize($localPath);
|
||||
|
||||
// Stream file to compute SHA-256 without loading into RAM
|
||||
$contentHash = hash_file('sha256', $localPath);
|
||||
$headers = $this->signRequest('PUT', $url, $contentHash, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Length' => (string) strlen($fileContent),
|
||||
'Content-Length' => (string) $fileSize,
|
||||
]);
|
||||
|
||||
$fp = fopen($localPath, 'rb');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException('Cannot open file for upload: ' . $localPath);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_PUT => true,
|
||||
CURLOPT_INFILE => $fp,
|
||||
CURLOPT_INFILESIZE => $fileSize,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
@@ -135,6 +144,8 @@ class S3Uploader implements RemoteUploaderInterface
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
fclose($fp);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Snapshot engine — creates lightweight JSON snapshots of specific content
|
||||
* types (articles, categories, modules) without touching the filesystem.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class SnapshotEngine
|
||||
{
|
||||
private array $log = [];
|
||||
|
||||
/** Content type => tables mapping */
|
||||
private const TYPE_TABLES = [
|
||||
'articles' => [
|
||||
'#__content',
|
||||
'#__content_frontpage',
|
||||
],
|
||||
'categories' => [
|
||||
'#__categories',
|
||||
],
|
||||
'modules' => [
|
||||
'#__modules',
|
||||
'#__modules_menu',
|
||||
],
|
||||
];
|
||||
|
||||
/** Related tables always captured when articles are included */
|
||||
private const ARTICLE_RELATED = [
|
||||
'#__workflow_associations',
|
||||
'#__contentitem_tag_map',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a snapshot of selected content types.
|
||||
*
|
||||
* @param array $contentTypes Types to snapshot: articles, categories, modules
|
||||
* @param string $description User-provided description
|
||||
*
|
||||
* @return array{success: bool, message: string, id?: int}
|
||||
*/
|
||||
public function create(array $contentTypes, string $description = ''): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
if (empty($contentTypes)) {
|
||||
return ['success' => false, 'message' => 'No content types selected'];
|
||||
}
|
||||
|
||||
$validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']);
|
||||
|
||||
if (empty($validTypes)) {
|
||||
return ['success' => false, 'message' => 'No valid content types selected'];
|
||||
}
|
||||
|
||||
$this->log('Starting snapshot: ' . implode(', ', $validTypes));
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'version' => 1,
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'content_types' => array_values($validTypes),
|
||||
'tables' => [],
|
||||
];
|
||||
|
||||
$counts = [
|
||||
'articles' => 0,
|
||||
'categories' => 0,
|
||||
'modules' => 0,
|
||||
];
|
||||
|
||||
// Dump each selected content type
|
||||
foreach ($validTypes as $type) {
|
||||
foreach (self::TYPE_TABLES[$type] as $abstractTable) {
|
||||
$realTable = str_replace('#__', $prefix, $abstractTable);
|
||||
$rows = $this->dumpTable($db, $realTable, $abstractTable, $type);
|
||||
$data['tables'][$abstractTable] = $rows;
|
||||
$this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows');
|
||||
}
|
||||
}
|
||||
|
||||
// Capture related tables for articles
|
||||
if (in_array('articles', $validTypes)) {
|
||||
$rows = $this->dumpFilteredTable(
|
||||
$db,
|
||||
str_replace('#__', $prefix, '#__workflow_associations'),
|
||||
'#__workflow_associations',
|
||||
'extension',
|
||||
'com_content.article'
|
||||
);
|
||||
$data['tables']['#__workflow_associations'] = $rows;
|
||||
$this->log(' #__workflow_associations: ' . count($rows) . ' rows');
|
||||
|
||||
$rows = $this->dumpTagMap($db, $prefix);
|
||||
$data['tables']['#__contentitem_tag_map'] = $rows;
|
||||
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
|
||||
}
|
||||
|
||||
// Count items
|
||||
if (in_array('articles', $validTypes)) {
|
||||
$counts['articles'] = count($data['tables']['#__content'] ?? []);
|
||||
}
|
||||
|
||||
if (in_array('categories', $validTypes)) {
|
||||
$counts['categories'] = count($data['tables']['#__categories'] ?? []);
|
||||
}
|
||||
|
||||
if (in_array('modules', $validTypes)) {
|
||||
$counts['modules'] = count($data['tables']['#__modules'] ?? []);
|
||||
}
|
||||
|
||||
// Write JSON file to backup directory
|
||||
$backupDir = BackupDirectory::getDefaultAbsolute();
|
||||
BackupDirectory::ensureReady($backupDir);
|
||||
|
||||
$filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json';
|
||||
$filePath = $backupDir . '/' . $filename;
|
||||
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to encode snapshot data as JSON');
|
||||
}
|
||||
|
||||
if (file_put_contents($filePath, $json) === false) {
|
||||
throw new \RuntimeException('Failed to write snapshot file: ' . $filePath);
|
||||
}
|
||||
|
||||
$fileSize = strlen($json);
|
||||
$this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)');
|
||||
|
||||
// Create database record
|
||||
$now = Factory::getDate()->toSql();
|
||||
$userId = Factory::getApplication()->getIdentity()->id ?? 0;
|
||||
|
||||
$record = (object) [
|
||||
'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes),
|
||||
'content_types' => json_encode(array_values($validTypes)),
|
||||
'status' => 'complete',
|
||||
'articles_count' => $counts['articles'],
|
||||
'categories_count' => $counts['categories'],
|
||||
'modules_count' => $counts['modules'],
|
||||
'data_file' => $filePath,
|
||||
'data_size' => $fileSize,
|
||||
'log' => implode("\n", $this->log),
|
||||
'created' => $now,
|
||||
'created_by' => $userId,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitebackup_snapshots', $record, 'id');
|
||||
|
||||
$this->log('Snapshot record created: ID ' . $record->id);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
'Snapshot created: %d articles, %d categories, %d modules',
|
||||
$counts['articles'],
|
||||
$counts['categories'],
|
||||
$counts['modules']
|
||||
),
|
||||
'id' => $record->id,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all rows from a table.
|
||||
*/
|
||||
private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array
|
||||
{
|
||||
$query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable));
|
||||
|
||||
// Filter categories to com_content only
|
||||
if ($abstractTable === '#__categories' && $type === 'categories') {
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump rows from a table filtered by a column value.
|
||||
*/
|
||||
private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($realTable))
|
||||
->where($db->quoteName($column) . ' = ' . $db->quote($value));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump tag map entries for com_content items.
|
||||
*/
|
||||
private function dumpTagMap(object $db, string $prefix): array
|
||||
{
|
||||
$table = $prefix . 'contentitem_tag_map';
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table))
|
||||
->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Restores content from a snapshot JSON file.
|
||||
*
|
||||
* Two restore modes:
|
||||
* - replace: Truncates target tables then inserts all snapshot rows (clean slate)
|
||||
* - merge: Upserts by primary key — updates existing rows, inserts new ones
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class SnapshotRestoreEngine
|
||||
{
|
||||
private array $log = [];
|
||||
|
||||
/** Primary key columns for each table */
|
||||
private const PRIMARY_KEYS = [
|
||||
'#__content' => 'id',
|
||||
'#__content_frontpage' => 'content_id',
|
||||
'#__categories' => 'id',
|
||||
'#__workflow_associations' => 'item_id',
|
||||
'#__contentitem_tag_map' => null, // composite key, handled specially
|
||||
'#__modules' => 'id',
|
||||
'#__modules_menu' => null, // composite key, handled specially
|
||||
];
|
||||
|
||||
/**
|
||||
* Restore from a snapshot record.
|
||||
*
|
||||
* @param int $snapshotId Snapshot record ID
|
||||
* @param string $mode 'replace' or 'merge'
|
||||
* @param array $contentTypes Which types to restore (empty = all from snapshot)
|
||||
*
|
||||
* @return array{success: bool, message: string, log?: string}
|
||||
*/
|
||||
public function restore(int $snapshotId, string $mode = 'replace', array $contentTypes = []): array
|
||||
{
|
||||
if (!@set_time_limit(0)) {
|
||||
$this->log('WARNING: Could not disable time limit — large restores may timeout');
|
||||
}
|
||||
|
||||
if (!@ini_set('memory_limit', '512M')) {
|
||||
$this->log('WARNING: Could not increase memory limit to 512M');
|
||||
}
|
||||
|
||||
if (!in_array($mode, ['replace', 'merge'])) {
|
||||
return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load snapshot record
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $snapshotId);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
|
||||
}
|
||||
|
||||
$this->log('Loading snapshot file: ' . basename($record->data_file));
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
return ['success' => false, 'message' => 'Cannot read snapshot file'];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
|
||||
}
|
||||
|
||||
if (!is_array($data) || empty($data['tables'])) {
|
||||
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
|
||||
}
|
||||
|
||||
$snapshotTypes = $data['content_types'] ?? [];
|
||||
$this->log('Snapshot contains: ' . implode(', ', $snapshotTypes));
|
||||
$this->log('Restore mode: ' . $mode);
|
||||
|
||||
// Determine which types to restore
|
||||
if (!empty($contentTypes)) {
|
||||
$restoreTypes = array_intersect($contentTypes, $snapshotTypes);
|
||||
} else {
|
||||
$restoreTypes = $snapshotTypes;
|
||||
}
|
||||
|
||||
if (empty($restoreTypes)) {
|
||||
return ['success' => false, 'message' => 'No matching content types to restore'];
|
||||
}
|
||||
|
||||
$this->log('Restoring types: ' . implode(', ', $restoreTypes));
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$totalRows = 0;
|
||||
|
||||
try {
|
||||
$db->transactionStart();
|
||||
|
||||
// Build list of tables to restore based on selected types
|
||||
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
|
||||
|
||||
foreach ($tablesToRestore as $abstractTable) {
|
||||
if (!isset($data['tables'][$abstractTable])) {
|
||||
$this->log(' Skipping ' . $abstractTable . ' (not in snapshot)');
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $data['tables'][$abstractTable];
|
||||
$realTable = str_replace('#__', $prefix, $abstractTable);
|
||||
|
||||
if ($mode === 'replace') {
|
||||
$rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows);
|
||||
} else {
|
||||
$rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows);
|
||||
}
|
||||
|
||||
$totalRows += $rowCount;
|
||||
$this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored');
|
||||
}
|
||||
|
||||
$db->transactionCommit();
|
||||
|
||||
$this->log('Restore complete: ' . $totalRows . ' total rows');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$db->transactionRollback();
|
||||
$this->log('Transaction rolled back');
|
||||
} catch (\Exception $rollbackEx) {
|
||||
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
||||
}
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace mode: delete existing rows, then insert all snapshot rows.
|
||||
*/
|
||||
private function restoreReplace(object $db, string $realTable, string $abstractTable, array $rows): int
|
||||
{
|
||||
// Use DELETE instead of TRUNCATE to stay within transaction
|
||||
$this->truncateFiltered($db, $realTable, $abstractTable, $rows);
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$obj = (object) $row;
|
||||
$db->insertObject($realTable, $obj);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge mode: upsert rows by primary key.
|
||||
*/
|
||||
private function restoreMerge(object $db, string $realTable, string $abstractTable, array $rows): int
|
||||
{
|
||||
$pk = self::PRIMARY_KEYS[$abstractTable] ?? null;
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$obj = (object) $row;
|
||||
|
||||
if ($pk !== null && isset($row[$pk])) {
|
||||
// Check if row exists
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName($realTable))
|
||||
->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk]))
|
||||
)->loadResult();
|
||||
|
||||
if ($exists) {
|
||||
$db->updateObject($realTable, $obj, $pk);
|
||||
} else {
|
||||
$db->insertObject($realTable, $obj);
|
||||
}
|
||||
} else {
|
||||
// Composite key tables — insert, skip genuine duplicates
|
||||
try {
|
||||
$db->insertObject($realTable, $obj);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) {
|
||||
$this->log(' Skipped duplicate in ' . $abstractTable);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows from a table, scoping to relevant content only.
|
||||
*
|
||||
* Shared tables (#__categories, #__modules, etc.) are filtered so
|
||||
* only the rows belonging to our content types are deleted — never
|
||||
* the entire table.
|
||||
*/
|
||||
private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): void
|
||||
{
|
||||
$query = $db->getQuery(true)->delete($db->quoteName($realTable));
|
||||
|
||||
switch ($abstractTable) {
|
||||
case '#__categories':
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
|
||||
break;
|
||||
|
||||
case '#__workflow_associations':
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content.article'));
|
||||
break;
|
||||
|
||||
case '#__contentitem_tag_map':
|
||||
$query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
|
||||
break;
|
||||
|
||||
case '#__modules':
|
||||
// Only delete modules that exist in the snapshot — never wipe all site modules
|
||||
$ids = array_filter(array_column($rows, 'id'));
|
||||
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = array_map('intval', $ids);
|
||||
$query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
|
||||
break;
|
||||
|
||||
case '#__modules_menu':
|
||||
// Only delete menu assignments for modules in the snapshot
|
||||
$moduleIds = array_filter(array_column($rows, 'moduleid'));
|
||||
|
||||
if (empty($moduleIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleIds = array_map('intval', array_unique($moduleIds));
|
||||
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
|
||||
break;
|
||||
|
||||
// #__content and #__content_frontpage are fully owned by com_content
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of abstract table names for the given content types.
|
||||
*/
|
||||
private function getTablesToRestore(array $types): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
if (in_array('articles', $types)) {
|
||||
$tables[] = '#__content';
|
||||
$tables[] = '#__content_frontpage';
|
||||
$tables[] = '#__workflow_associations';
|
||||
$tables[] = '#__contentitem_tag_map';
|
||||
}
|
||||
|
||||
if (in_array('categories', $types)) {
|
||||
$tables[] = '#__categories';
|
||||
}
|
||||
|
||||
if (in_array('modules', $types)) {
|
||||
$tables[] = '#__modules';
|
||||
$tables[] = '#__modules_menu';
|
||||
}
|
||||
|
||||
return array_unique($tables);
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,18 @@ class SteppedBackupEngine
|
||||
*/
|
||||
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -43,7 +55,7 @@ class SteppedBackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Create session
|
||||
@@ -130,6 +142,11 @@ class SteppedBackupEngine
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
// Log any preflight warnings into the session
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
$session->statusMessage = 'Initialized — starting backup...';
|
||||
$session->save();
|
||||
|
||||
@@ -138,6 +155,7 @@ class SteppedBackupEngine
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -220,8 +238,7 @@ class SteppedBackupEngine
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Dump this single table
|
||||
$dumper = new DatabaseDumper([]);
|
||||
$sql = $this->dumpSingleTable($db, $table);
|
||||
$sql = $this->dumpSingleTable($db, $table);
|
||||
|
||||
// Append to a temp SQL file that will be added to ZIP in finalize
|
||||
$sqlFile = $session->archivePath . '.sql';
|
||||
@@ -234,8 +251,9 @@ class SteppedBackupEngine
|
||||
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
|
||||
. "SET time_zone = \"+00:00\";\n\n";
|
||||
if (file_put_contents($sqlFile, $header) === false) {
|
||||
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
|
||||
}
|
||||
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
|
||||
}
|
||||
|
||||
$flags = FILE_APPEND;
|
||||
}
|
||||
|
||||
@@ -434,12 +452,14 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
$totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0;
|
||||
$checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : '';
|
||||
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'status' => 'complete',
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'total_size' => $totalSize,
|
||||
'checksum' => $checksum,
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
@@ -471,7 +491,7 @@ class SteppedBackupEngine
|
||||
|
||||
NotificationSender::send($profile, $record, true, $logContent);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -529,13 +549,16 @@ class SteppedBackupEngine
|
||||
*/
|
||||
private function dumpSingleTable(object $db, string $table): string
|
||||
{
|
||||
$prefix = $db->getPrefix();
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
$output = [];
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
$output[] = '-- Table: ' . $abstractName;
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '';
|
||||
|
||||
// CREATE TABLE
|
||||
// CREATE TABLE — replace live prefix with #__
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
@@ -543,8 +566,10 @@ class SteppedBackupEngine
|
||||
return '';
|
||||
}
|
||||
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
// Replace all occurrences of the live prefix — covers FK REFERENCES too
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
|
||||
$output[] = $createSql . ';';
|
||||
$output[] = '';
|
||||
|
||||
// Data in chunks
|
||||
@@ -580,7 +605,7 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
$output[] = 'INSERT INTO ' . $db->quoteName($table)
|
||||
$output[] = 'INSERT INTO `' . $abstractName . '`'
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ');';
|
||||
}
|
||||
|
||||
@@ -47,12 +47,14 @@ class TarGzArchiver implements ArchiverInterface
|
||||
|
||||
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);
|
||||
try {
|
||||
// Compress the .tar to .tar.gz
|
||||
$this->tar->compress(\Phar::GZ);
|
||||
} finally {
|
||||
// Always remove the uncompressed .tar, even if compress() fails
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Custom field for IP whitelist management.
|
||||
* Shows current user's IP and presents entries as a table.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class IpWhitelistField extends FormField
|
||||
{
|
||||
protected $type = 'IpWhitelist';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = trim($this->value ?? '');
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
$currentIp = $this->getCurrentIp();
|
||||
|
||||
$ips = array_filter(array_map('trim', explode(',', $value)));
|
||||
|
||||
$html = '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="' . htmlspecialchars($value) . '">';
|
||||
|
||||
// Current IP display
|
||||
$html .= '<div class="alert alert-info py-2 mb-2">'
|
||||
. '<span class="icon-location" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP') . ': '
|
||||
. '<strong class="font-monospace">' . htmlspecialchars($currentIp) . '</strong>';
|
||||
|
||||
$alreadyAdded = in_array($currentIp, $ips);
|
||||
if (!$alreadyAdded) {
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-primary ms-2"'
|
||||
. ' onclick="mokoIpAdd(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($currentIp) . '\')">'
|
||||
. '<span class="icon-plus" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP')
|
||||
. '</button>';
|
||||
} else {
|
||||
$html .= ' <span class="badge bg-success ms-2">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED') . '</span>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// IP table
|
||||
$html .= '<table class="table table-sm table-bordered" id="' . htmlspecialchars($id) . '-table">';
|
||||
$html .= '<thead><tr>'
|
||||
. '<th>' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS') . '</th>'
|
||||
. '<th class="w-10 text-center">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE') . '</th>'
|
||||
. '</tr></thead>';
|
||||
$html .= '<tbody>';
|
||||
|
||||
if (empty($ips)) {
|
||||
$html .= '<tr class="moko-ip-empty"><td colspan="2" class="text-muted text-center">'
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE')
|
||||
. '</td></tr>';
|
||||
} else {
|
||||
foreach ($ips as $ip) {
|
||||
$html .= '<tr>'
|
||||
. '<td class="font-monospace">' . htmlspecialchars($ip) . '</td>'
|
||||
. '<td class="text-center">'
|
||||
. '<button type="button" class="btn btn-sm btn-outline-danger"'
|
||||
. ' onclick="mokoIpRemove(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($ip) . '\')">'
|
||||
. '<span class="icon-times" aria-hidden="true"></span>'
|
||||
. '</button></td>'
|
||||
. '</tr>';
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
|
||||
// Add custom IP
|
||||
$html .= '<div class="input-group input-group-sm" style="max-width:350px;">';
|
||||
$html .= '<input type="text" class="form-control font-monospace" id="' . htmlspecialchars($id) . '-new"'
|
||||
. ' placeholder="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER') . '"'
|
||||
. ' pattern="[0-9a-fA-F.:\/]+">';
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary"'
|
||||
. ' onclick="mokoIpAddCustom(\'' . htmlspecialchars($id) . '\')">'
|
||||
. '<span class="icon-plus" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD')
|
||||
. '</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= $this->getScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getCurrentIp(): string
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
// Try standard header first, then forwarded headers
|
||||
$ip = $app->input->server->getString('REMOTE_ADDR', '');
|
||||
|
||||
// Check forwarded headers (common behind reverse proxies)
|
||||
$forwarded = $app->input->server->getString('HTTP_X_FORWARDED_FOR', '');
|
||||
|
||||
if (!empty($forwarded)) {
|
||||
// Take the first IP in the chain (client IP)
|
||||
$parts = explode(',', $forwarded);
|
||||
$candidate = trim($parts[0]);
|
||||
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
$ip = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $ip ?: '0.0.0.0';
|
||||
}
|
||||
|
||||
private function getScript(): string
|
||||
{
|
||||
$noneText = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE'));
|
||||
|
||||
return <<<JS
|
||||
<script>
|
||||
function mokoIpGetList(fieldId) {
|
||||
var val = document.getElementById(fieldId).value.trim();
|
||||
return val ? val.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function mokoIpSync(fieldId, ips) {
|
||||
document.getElementById(fieldId).value = ips.join(', ');
|
||||
mokoIpRebuildTable(fieldId, ips);
|
||||
}
|
||||
|
||||
function mokoIpRebuildTable(fieldId, ips) {
|
||||
var tbody = document.querySelector('#' + fieldId + '-table tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
if (ips.length === 0) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'moko-ip-empty';
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.className = 'text-muted text-center';
|
||||
td.textContent = {$noneText};
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
ips.forEach(function(ip) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdIp = document.createElement('td');
|
||||
tdIp.className = 'font-monospace';
|
||||
tdIp.textContent = ip;
|
||||
tr.appendChild(tdIp);
|
||||
|
||||
var tdAct = document.createElement('td');
|
||||
tdAct.className = 'text-center';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-danger';
|
||||
btn.onclick = function() { mokoIpRemove(fieldId, ip); };
|
||||
var span = document.createElement('span');
|
||||
span.className = 'icon-times';
|
||||
btn.appendChild(span);
|
||||
tdAct.appendChild(btn);
|
||||
tr.appendChild(tdAct);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function mokoIpAdd(fieldId, ip) {
|
||||
var ips = mokoIpGetList(fieldId);
|
||||
if (ips.indexOf(ip) === -1) {
|
||||
ips.push(ip);
|
||||
mokoIpSync(fieldId, ips);
|
||||
}
|
||||
}
|
||||
|
||||
function mokoIpRemove(fieldId, ip) {
|
||||
var ips = mokoIpGetList(fieldId).filter(function(i) { return i !== ip; });
|
||||
mokoIpSync(fieldId, ips);
|
||||
}
|
||||
|
||||
function mokoIpAddCustom(fieldId) {
|
||||
var input = document.getElementById(fieldId + '-new');
|
||||
var ip = input.value.trim();
|
||||
if (!ip) return;
|
||||
mokoIpAdd(fieldId, ip);
|
||||
input.value = '';
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Custom field for the webcron secret word.
|
||||
* Generates a random default and validates minimum strength.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class WebcronSecretField extends FormField
|
||||
{
|
||||
protected $type = 'WebcronSecret';
|
||||
|
||||
private const MIN_LENGTH = 16;
|
||||
private const WEAK_PATTERNS = [
|
||||
'password', 'secret', '123456', 'admin', 'backup',
|
||||
'test', 'webcron', 'qwerty', 'letmein', 'welcome',
|
||||
];
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = $this->value ?? '';
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
$maxLength = (int) ($this->element['maxlength'] ?? 64);
|
||||
|
||||
$strengthHtml = '';
|
||||
$strengthClass = 'text-muted';
|
||||
$strengthText = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
|
||||
|
||||
if (!empty($value)) {
|
||||
$strength = $this->evaluateStrength($value);
|
||||
$strengthClass = $strength['class'];
|
||||
$strengthText = $strength['label'];
|
||||
}
|
||||
|
||||
$html = '<div class="input-group">';
|
||||
$html .= '<input type="text" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="' . htmlspecialchars($value) . '"'
|
||||
. ' class="form-control" maxlength="' . $maxLength . '"'
|
||||
. ' autocomplete="off" spellcheck="false"'
|
||||
. ' onchange="mokoWebcronCheckStrength(this)"'
|
||||
. ' onkeyup="mokoWebcronCheckStrength(this)">';
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary" onclick="mokoWebcronGenerate(\'' . htmlspecialchars($id) . '\')"'
|
||||
. ' title="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE') . '">'
|
||||
. '<span class="icon-refresh" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE')
|
||||
. '</button>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div id="' . htmlspecialchars($id) . '-strength" class="small mt-1 ' . $strengthClass . '">'
|
||||
. $strengthText . '</div>';
|
||||
|
||||
$html .= $this->getScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function evaluateStrength(string $value): array
|
||||
{
|
||||
$len = strlen($value);
|
||||
|
||||
// Check weak patterns
|
||||
$lower = strtolower($value);
|
||||
foreach (self::WEAK_PATTERNS as $weak) {
|
||||
if (str_contains($lower, $weak)) {
|
||||
return [
|
||||
'class' => 'text-danger fw-bold',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($len < self::MIN_LENGTH) {
|
||||
return [
|
||||
'class' => 'text-danger',
|
||||
'label' => Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', self::MIN_LENGTH),
|
||||
];
|
||||
}
|
||||
|
||||
$hasUpper = preg_match('/[A-Z]/', $value);
|
||||
$hasLower = preg_match('/[a-z]/', $value);
|
||||
$hasDigit = preg_match('/[0-9]/', $value);
|
||||
|
||||
if ($hasUpper && $hasLower && $hasDigit && $len >= 32) {
|
||||
return [
|
||||
'class' => 'text-success',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasUpper && $hasLower && $hasDigit) {
|
||||
return [
|
||||
'class' => 'text-warning',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'class' => 'text-danger',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getScript(): string
|
||||
{
|
||||
$minLen = self::MIN_LENGTH;
|
||||
$weakJson = json_encode(self::WEAK_PATTERNS);
|
||||
$labelNone = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
|
||||
$labelShort = json_encode(Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', $minLen));
|
||||
$labelWeak = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'));
|
||||
$labelOk = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'));
|
||||
$labelStrong = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'));
|
||||
|
||||
return <<<JS
|
||||
<script>
|
||||
function mokoWebcronGenerate(fieldId) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
var result = '';
|
||||
var arr = new Uint8Array(32);
|
||||
(window.crypto || window.msCrypto).getRandomValues(arr);
|
||||
for (var i = 0; i < 32; i++) {
|
||||
result += chars.charAt(arr[i] % chars.length);
|
||||
}
|
||||
var field = document.getElementById(fieldId);
|
||||
field.value = result;
|
||||
mokoWebcronCheckStrength(field);
|
||||
}
|
||||
|
||||
function mokoWebcronCheckStrength(field) {
|
||||
var val = field.value;
|
||||
var el = document.getElementById(field.id + '-strength');
|
||||
var weak = {$weakJson};
|
||||
var lower = val.toLowerCase();
|
||||
|
||||
if (!val) {
|
||||
el.className = 'small mt-1 text-muted';
|
||||
el.textContent = '{$labelNone}';
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < weak.length; i++) {
|
||||
if (lower.indexOf(weak[i]) !== -1) {
|
||||
el.className = 'small mt-1 text-danger fw-bold';
|
||||
el.textContent = {$labelWeak};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (val.length < {$minLen}) {
|
||||
el.className = 'small mt-1 text-danger';
|
||||
el.textContent = {$labelShort};
|
||||
return;
|
||||
}
|
||||
|
||||
var hasUpper = /[A-Z]/.test(val);
|
||||
var hasLower = /[a-z]/.test(val);
|
||||
var hasDigit = /[0-9]/.test(val);
|
||||
|
||||
if (hasUpper && hasLower && hasDigit && val.length >= 32) {
|
||||
el.className = 'small mt-1 text-success';
|
||||
el.textContent = {$labelStrong};
|
||||
} else if (hasUpper && hasLower && hasDigit) {
|
||||
el.className = 'small mt-1 text-warning';
|
||||
el.textContent = {$labelOk};
|
||||
} else {
|
||||
el.className = 'small mt-1 text-danger';
|
||||
el.textContent = {$labelWeak};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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\MokoSuiteBackup\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
/**
|
||||
* Lightweight helper for external consumers (bridge plugins, MCP servers, CLI tools)
|
||||
* to query MokoSuiteBackup status without bootstrapping the full component.
|
||||
*
|
||||
* Usage from any Joomla plugin:
|
||||
* \Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper::getStatusSummary()
|
||||
*/
|
||||
class BackupStatusHelper
|
||||
{
|
||||
/**
|
||||
* Check whether MokoSuiteBackup is installed and enabled.
|
||||
*/
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
|
||||
->where($db->quoteName('enabled') . ' = 1');
|
||||
|
||||
return (int) $db->setQuery($query)->loadResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest backup record for a given profile (or any profile).
|
||||
*
|
||||
* @param int|null $profileId Limit to a specific profile, or null for any.
|
||||
* @return object|null Record object or null if no backups exist.
|
||||
*/
|
||||
public static function getLatestRecord(?int $profileId = null): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r.id'),
|
||||
$db->quoteName('r.profile_id'),
|
||||
$db->quoteName('r.description'),
|
||||
$db->quoteName('r.status'),
|
||||
$db->quoteName('r.origin'),
|
||||
$db->quoteName('r.backup_type'),
|
||||
$db->quoteName('r.archivename'),
|
||||
$db->quoteName('r.total_size'),
|
||||
$db->quoteName('r.db_size'),
|
||||
$db->quoteName('r.files_count'),
|
||||
$db->quoteName('r.tables_count'),
|
||||
$db->quoteName('r.backupstart'),
|
||||
$db->quoteName('r.backupend'),
|
||||
$db->quoteName('r.filesexist'),
|
||||
$db->quoteName('r.remote_filename'),
|
||||
$db->quoteName('r.checksum'),
|
||||
$db->quoteName('p.title', 'profile_title'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
||||
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')')
|
||||
->order($db->quoteName('r.backupstart') . ' DESC');
|
||||
|
||||
if ($profileId !== null) {
|
||||
$query->where($db->quoteName('r.profile_id') . ' = ' . (int) $profileId);
|
||||
}
|
||||
|
||||
$query->setLimit(1);
|
||||
$record = $db->setQuery($query)->loadObject();
|
||||
|
||||
return $record ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a full status summary for heartbeat payloads.
|
||||
*
|
||||
* Returns an array suitable for JSON encoding in bridge heartbeats:
|
||||
* - latest backup status, time, size, destination
|
||||
* - total and recent (7-day) backup counts
|
||||
* - streak of consecutive successes
|
||||
*
|
||||
* @return array{installed: bool, latest: ?array, totals: array}
|
||||
*/
|
||||
public static function getStatusSummary(): array
|
||||
{
|
||||
if (!self::isInstalled()) {
|
||||
return ['installed' => false, 'latest' => null, 'totals' => []];
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
// Latest completed/failed backup
|
||||
$latest = self::getLatestRecord();
|
||||
$latestArray = null;
|
||||
|
||||
if ($latest) {
|
||||
$latestArray = [
|
||||
'status' => $latest->status,
|
||||
'backup_type' => $latest->backup_type,
|
||||
'description' => $latest->description,
|
||||
'backup_start' => $latest->backupstart,
|
||||
'backup_end' => $latest->backupend,
|
||||
'total_size' => (int) $latest->total_size,
|
||||
'destination' => $latest->remote_filename ? 'remote' : 'local',
|
||||
'profile' => $latest->profile_title,
|
||||
'origin' => $latest->origin,
|
||||
'files_count' => (int) $latest->files_count,
|
||||
'tables_count' => (int) $latest->tables_count,
|
||||
];
|
||||
}
|
||||
|
||||
// Totals
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('complete') . ' THEN 1 ELSE 0 END) AS success',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS failed',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitebackup_records'));
|
||||
|
||||
$allTime = $db->setQuery($query)->loadObject();
|
||||
|
||||
// Recent (last 7 days)
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('complete') . ' THEN 1 ELSE 0 END) AS success',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS failed',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('backupstart') . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)');
|
||||
|
||||
$recent = $db->setQuery($query)->loadObject();
|
||||
|
||||
// Success streak — count consecutive successes from latest backward
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('status'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')')
|
||||
->order($db->quoteName('backupstart') . ' DESC')
|
||||
->setLimit(50);
|
||||
|
||||
$statuses = $db->setQuery($query)->loadColumn();
|
||||
$streak = 0;
|
||||
|
||||
foreach ($statuses as $s) {
|
||||
if ($s === 'complete') {
|
||||
$streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'installed' => true,
|
||||
'latest' => $latestArray,
|
||||
'totals' => [
|
||||
'all_time' => (int) ($allTime->total ?? 0),
|
||||
'all_success' => (int) ($allTime->success ?? 0),
|
||||
'all_failed' => (int) ($allTime->failed ?? 0),
|
||||
'recent_total' => (int) ($recent->total ?? 0),
|
||||
'recent_success' => (int) ($recent->success ?? 0),
|
||||
'recent_failed' => (int) ($recent->failed ?? 0),
|
||||
'success_streak' => $streak,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class BackupModel extends AdminModel
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
return is_array($data) ? (object) $data : $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Backup', $prefix = 'Administrator', $options = [])
|
||||
|
||||
@@ -61,6 +61,13 @@ class BackupsModel extends ListModel
|
||||
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
|
||||
}
|
||||
|
||||
// Filter by backup type
|
||||
$backupType = $this->getState('filter.backup_type');
|
||||
|
||||
if (!empty($backupType)) {
|
||||
$query->where($db->quoteName('a.backup_type') . ' = ' . $db->quote($backupType));
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ProfileModel extends AdminModel
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
return is_array($data) ? (object) $data : $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Profile', $prefix = 'Administrator', $options = [])
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class SnapshotModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get a single snapshot record.
|
||||
*
|
||||
* @param int $pk Primary key
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
public function getItem(int $pk = 0): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $pk);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObject() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
|
||||
class SnapshotsModel extends ListModel
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields'])) {
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'description', 'a.description',
|
||||
'created', 'a.created',
|
||||
'data_size', 'a.data_size',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
protected function getListQuery()
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots', 'a'));
|
||||
|
||||
// Search filter
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search)) {
|
||||
$search = $db->quote('%' . $db->escape($search, true) . '%');
|
||||
$query->where($db->quoteName('a.description') . ' LIKE ' . $search);
|
||||
}
|
||||
|
||||
// Ordering
|
||||
$orderCol = $this->state->get('list.ordering', 'a.created');
|
||||
$orderDirn = $this->state->get('list.direction', 'DESC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.created', $direction = 'DESC')
|
||||
{
|
||||
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
|
||||
$this->setState('filter.search', $search);
|
||||
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,22 @@ class BackupTable extends Table
|
||||
|
||||
public function delete($pk = null): bool
|
||||
{
|
||||
// Delete the archive file if it exists
|
||||
if (!empty($this->absolute_path) && is_file($this->absolute_path)) {
|
||||
@unlink($this->absolute_path);
|
||||
$archivePath = $this->absolute_path;
|
||||
|
||||
// Delete DB record first — if this fails, the file is preserved
|
||||
$result = parent::delete($pk);
|
||||
|
||||
if ($result && !empty($archivePath) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
|
||||
// Also remove the log file if it exists alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
||||
|
||||
if (is_file($logPath)) {
|
||||
@unlink($logPath);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::delete($pk);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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\MokoSuiteBackup\Administrator\View\Snapshots;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
protected $state;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera');
|
||||
|
||||
if ($user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('snapshots.create', 'plus', '', 'COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE', false);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'snapshots.delete');
|
||||
}
|
||||
|
||||
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::preferences('com_mokosuitebackup');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
@@ -311,6 +318,34 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
// Expose for toolbar button
|
||||
window.mokosuitebackupStart = startSteppedBackup;
|
||||
|
||||
// Intercept Restore toolbar button to show the modal
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get selected record from checkboxes
|
||||
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
||||
if (checked.length === 0) {
|
||||
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', true); ?>');
|
||||
return false;
|
||||
}
|
||||
document.getElementById('mb-restore-record-id').value = checked[0].value;
|
||||
document.getElementById('mb-restore-modal').style.display = 'block';
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Close restore modal
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') {
|
||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// View Log modal handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-view-log');
|
||||
@@ -353,6 +388,61 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Restore Confirmation Modal -->
|
||||
<div id="mb-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-restore-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
|
||||
<label class="form-check-label" for="mb-restore-files">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
|
||||
<label class="form-check-label" for="mb-restore-db">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
|
||||
<label class="form-check-label" for="mb-restore-config">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
|
||||
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
|
||||
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
|
||||
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-restore-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer Modal -->
|
||||
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
||||
|
||||
@@ -118,8 +118,8 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($this->profiles)) : ?>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<select id="mb-profile-select" class="form-select" style="max-width:250px;">
|
||||
<div class="mb-3">
|
||||
<select id="mb-profile-select" class="form-select mb-2">
|
||||
<?php foreach ($this->profiles as $profile) : ?>
|
||||
<option value="<?php echo (int) $profile->id; ?>">
|
||||
<?php echo $this->escape($profile->title); ?>
|
||||
@@ -127,7 +127,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" onclick="window.mokosuitebackupStart()">
|
||||
<button type="button" class="btn btn-primary w-100" onclick="window.mokosuitebackupStart()">
|
||||
<span class="icon-download" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
|
||||
</button>
|
||||
@@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$canManage = $user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup');
|
||||
|
||||
$listOrder = $this->escape($this->state->get('list.ordering'));
|
||||
$listDirn = $this->escape($this->state->get('list.direction'));
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="j-main-container" class="j-main-container">
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table" id="snapshotList">
|
||||
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION'); ?></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="w-1 text-center">
|
||||
<?php echo HTMLHelper::_('grid.checkall'); ?>
|
||||
</td>
|
||||
<th scope="col">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-15">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_SIZE', 'a.data_size', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.created', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $i => $item) : ?>
|
||||
<?php $types = json_decode($item->content_types, true) ?: []; ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->description); ?>
|
||||
<?php if ($item->status === 'fail') : ?>
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php foreach ($types as $type) : ?>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td class="text-center"><?php echo (int) $item->articles_count; ?></td>
|
||||
<td class="text-center"><?php echo (int) $item->categories_count; ?></td>
|
||||
<td class="text-center"><?php echo (int) $item->modules_count; ?></td>
|
||||
<td>
|
||||
<?php echo $item->data_size > 0 ? HTMLHelper::_('number.bytes', $item->data_size) : '—'; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->created, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>">
|
||||
<span class="icon-upload"></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo (int) $item->id; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" name="task" value="">
|
||||
<input type="hidden" name="boxchecked" value="0">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Create Snapshot Modal -->
|
||||
<div id="mb-snapshot-create-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="mb-3">
|
||||
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
||||
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="articles" id="mb-snap-articles" checked>
|
||||
<label class="form-check-label" for="mb-snap-articles">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="categories" id="mb-snap-categories" checked>
|
||||
<label class="form-check-label" for="mb-snap-categories">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="modules" id="mb-snap-modules" checked>
|
||||
<label class="form-check-label" for="mb-snap-modules">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-camera" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Snapshot Modal -->
|
||||
<div id="mb-snapshot-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<p id="mb-restore-desc" class="fw-bold"></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="replace" id="mb-mode-replace" checked>
|
||||
<label class="form-check-label" for="mb-mode-replace">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="merge" id="mb-mode-merge">
|
||||
<label class="form-check-label" for="mb-mode-merge">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="mb-restore-types-container">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
||||
<!-- Populated by JS from data-types -->
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Create Snapshot — intercept toolbar button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var createBtn = document.querySelector('[onclick*="snapshots.create"], .button-plus');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'block';
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore Snapshot — click handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-snapshot-restore');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var id = btn.getAttribute('data-id');
|
||||
var types = JSON.parse(btn.getAttribute('data-types') || '[]');
|
||||
var desc = btn.getAttribute('data-desc');
|
||||
|
||||
document.getElementById('mb-restore-id').value = id;
|
||||
document.getElementById('mb-restore-desc').textContent = 'Restoring: ' + desc;
|
||||
|
||||
// Build type checkboxes using safe DOM methods
|
||||
var container = document.getElementById('mb-restore-types-container');
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
var heading = document.createElement('label');
|
||||
heading.className = 'form-label fw-bold';
|
||||
heading.textContent = <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES')); ?>;
|
||||
container.appendChild(heading);
|
||||
|
||||
var typeLabels = {
|
||||
articles: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES')); ?>,
|
||||
categories: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES')); ?>,
|
||||
modules: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES')); ?>
|
||||
};
|
||||
var allowedTypes = ['articles', 'categories', 'modules'];
|
||||
|
||||
types.forEach(function(type) {
|
||||
if (allowedTypes.indexOf(type) === -1) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'form-check';
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.className = 'form-check-input';
|
||||
input.type = 'checkbox';
|
||||
input.name = 'restore_types[]';
|
||||
input.value = type;
|
||||
input.id = 'mb-rtype-' + type;
|
||||
input.checked = true;
|
||||
|
||||
var label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.setAttribute('for', 'mb-rtype-' + type);
|
||||
label.textContent = typeLabels[type] || type;
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(label);
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Show/hide replace warning based on mode
|
||||
toggleReplaceWarning();
|
||||
|
||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'block';
|
||||
});
|
||||
|
||||
// Toggle warning when mode changes
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.name === 'restore_mode') {
|
||||
toggleReplaceWarning();
|
||||
}
|
||||
});
|
||||
|
||||
function toggleReplaceWarning() {
|
||||
var isReplace = document.getElementById('mb-mode-replace').checked;
|
||||
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Close modals
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-modal-close') ||
|
||||
e.target.id === 'mb-snapshot-create-modal' ||
|
||||
e.target.id === 'mb-snapshot-restore-modal') {
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -86,7 +86,7 @@ class RestoreCommand extends AbstractCommand
|
||||
}
|
||||
|
||||
$engine = new RestoreEngine();
|
||||
$result = $engine->restore($record->absolute_path, $record->backup_type);
|
||||
$result = $engine->restore($recordId);
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success($result['message']);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -59,11 +59,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
// Reject if disabled or no secret configured
|
||||
if (!$enabled || $configSecret === '') {
|
||||
$this->sendJsonResponse(false, 'Web cron is not enabled', 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate secret (timing-safe comparison)
|
||||
if (!hash_equals($configSecret, $secret)) {
|
||||
$this->sendJsonResponse(false, 'Invalid secret', 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// IP whitelist check (if configured)
|
||||
@@ -73,6 +77,8 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
if (!in_array($clientIp, $allowedIps, true)) {
|
||||
$this->sendJsonResponse(false, 'IP not allowed', 403);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +144,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
* A profile value of 0 means "use the global default".
|
||||
*/
|
||||
private function cleanupOldBackups(): void
|
||||
{
|
||||
try {
|
||||
$this->doCleanup();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: cleanupOldBackups() failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function doCleanup(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
|
||||
@@ -219,10 +234,11 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
|
||||
if (!@unlink($record->absolute_path)) {
|
||||
return; // Don't delete DB record if file can't be removed
|
||||
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Also remove the log file if it exists alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
|
||||
|
||||
if (is_file($logPath)) {
|
||||
@@ -230,12 +246,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $record->id)
|
||||
);
|
||||
$db->execute();
|
||||
try {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $record->id)
|
||||
);
|
||||
$db->execute();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: Could not delete backup record ' . $record->id . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,7 +311,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: ' . $description . ' failed: ' . $e->getMessage());
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'MokoSuiteBackup: ' . $description . ' failed — ' . $e->getMessage(),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.28.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -150,6 +150,9 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
// Create default backup directory in site root
|
||||
$this->createBackupDirectory();
|
||||
|
||||
// Generate a random webcron secret word
|
||||
$this->generateWebcronSecret();
|
||||
|
||||
// Create default scheduled task for backup automation
|
||||
$this->createDefaultScheduledTask();
|
||||
}
|
||||
@@ -185,6 +188,58 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random webcron secret word on fresh install.
|
||||
*/
|
||||
private function generateWebcronSecret(): void
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load current component params
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
$rawParams = $db->loadResult();
|
||||
|
||||
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
||||
|
||||
// Only generate if not already set
|
||||
if (!empty($params['webcron_secret'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
$secret = '';
|
||||
$bytes = random_bytes(32);
|
||||
|
||||
for ($i = 0; $i < 32; $i++) {
|
||||
$secret .= $chars[ord($bytes[$i]) % strlen($chars)];
|
||||
}
|
||||
|
||||
$params['webcron_secret'] = $secret;
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: generateWebcronSecret() failed: ' . $e->getMessage());
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'MokoSuiteBackup could not generate a random webcron secret. '
|
||||
. 'Please set one manually in the component options to secure the webcron endpoint.',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function enableBundledPlugins(): void
|
||||
{
|
||||
$folders = ['system', 'quickicon', 'task', 'webservices', 'console', 'content', 'actionlog'];
|
||||
@@ -388,6 +443,12 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
'img' => 'class:database',
|
||||
'menu_icon' => 'icon-database',
|
||||
],
|
||||
[
|
||||
'link' => 'index.php?option=com_mokosuitebackup&view=snapshots',
|
||||
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS',
|
||||
'img' => 'class:camera',
|
||||
'menu_icon' => 'icon-camera',
|
||||
],
|
||||
[
|
||||
'link' => 'index.php?option=com_mokosuitebackup&view=profiles',
|
||||
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_PROFILES',
|
||||
@@ -522,6 +583,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
$iconMap = [
|
||||
'view=dashboard' => 'class:home',
|
||||
'view=backups' => 'class:database',
|
||||
'view=snapshots' => 'class:camera',
|
||||
'view=profiles' => 'class:cog',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage plg_webservices_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
|
||||
*
|
||||
* Akeeba-compatible routes:
|
||||
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
|
||||
* GET /api/index.php/v1/mokosuitebackup/backups — List records
|
||||
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
|
||||
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
|
||||
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Router\ApiRouter;
|
||||
use Joomla\Event\Event;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Joomla\Router\Route;
|
||||
|
||||
final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onBeforeApiRoute' => 'onBeforeApiRoute',
|
||||
];
|
||||
}
|
||||
|
||||
public function onBeforeApiRoute(Event $event): void
|
||||
{
|
||||
/** @var ApiRouter $router */
|
||||
[$router] = array_values($event->getArguments());
|
||||
|
||||
$defaults = [
|
||||
'component' => 'com_mokosuitebackup',
|
||||
'public' => false,
|
||||
];
|
||||
|
||||
// Standard CRUD for backup records
|
||||
$router->createCRUDRoutes('v1/mokosuitebackup/backups', 'backups', $defaults);
|
||||
|
||||
// Start a backup (POST)
|
||||
$router->addRoute(
|
||||
new Route(
|
||||
['POST'],
|
||||
'v1/mokosuitebackup/backup',
|
||||
'backups.backup',
|
||||
[],
|
||||
$defaults
|
||||
)
|
||||
);
|
||||
|
||||
// Delete a backup (DELETE)
|
||||
$router->addRoute(
|
||||
new Route(
|
||||
['DELETE'],
|
||||
'v1/mokosuitebackup/backup/:id',
|
||||
'backups.delete',
|
||||
['id' => '(\d+)'],
|
||||
$defaults
|
||||
)
|
||||
);
|
||||
|
||||
// Download a backup archive (GET)
|
||||
$router->addRoute(
|
||||
new Route(
|
||||
['GET'],
|
||||
'v1/mokosuitebackup/backup/:id/download',
|
||||
'backups.download',
|
||||
['id' => '(\d+)'],
|
||||
$defaults
|
||||
)
|
||||
);
|
||||
|
||||
// List backup profiles (GET)
|
||||
$router->addRoute(
|
||||
new Route(
|
||||
['GET'],
|
||||
'v1/mokosuitebackup/profiles',
|
||||
'backups.profiles',
|
||||
[],
|
||||
$defaults
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user