Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 093bc45d05 | |||
| 8bdadb68c2 | |||
| 0cba7f81e8 | |||
| 2688ea352b | |||
| 39d94a31d6 | |||
| 1029e40908 | |||
| ec3094385f | |||
| 4a2bb1b19d | |||
| 3c5ad43e97 | |||
| f92e5227dc | |||
| 9db54ac60c | |||
| 0c7c3552ed | |||
| 351321161a | |||
| d2cc234fce | |||
| 2389df8670 | |||
| 55f29498b0 | |||
| a5397c0f16 | |||
| c0792a6ba0 | |||
| 7a3f0f914e | |||
| cc7f18ae45 | |||
| 4cf8f8243d | |||
| efb28fa386 | |||
| 68043c9a37 | |||
| e5558fad26 | |||
| 60c5c00333 | |||
| f9169fd9f7 | |||
| d69db6fac3 | |||
| 1f7c2ef1ba | |||
| 24d7dc0064 | |||
| f4bc1d128c | |||
| 6ab31c12be | |||
| 99b844e04a | |||
| 75bbb55e29 | |||
| 11b0ea3c21 | |||
| bcb4368bd1 | |||
| 8ac3bedff3 | |||
| 4ab9ab3806 | |||
| 4740a09d79 | |||
| 26121e3628 | |||
| 478adfcde8 | |||
| f4b6e23d58 | |||
| af215482bd | |||
| cd721d450e | |||
| b38ddb5b62 | |||
| 6e66de4c65 | |||
| b2d35bc9b3 | |||
| d780e10a2f | |||
| b2770df80f | |||
| 1c643b097f | |||
| ee293e8038 | |||
| 8e3e473426 | |||
| 2a7fb74ef9 | |||
| c7a57116a7 | |||
| 6a62868881 | |||
| 0926e82465 | |||
| e37a52df4e | |||
| a7611d17d9 | |||
| 183b2d2d6f | |||
| 4ca9e23630 | |||
| d0ce09abf0 | |||
| 1dde736225 | |||
| 6074aa55ae | |||
| 939964bff9 | |||
| 5363a85b92 | |||
| c12d207a96 | |||
| ee11ec8ef7 | |||
| 0c9d9a6336 | |||
| 882cb2c48c | |||
| 0578f2deed | |||
| eecb8c8c72 | |||
| f0fa645e51 | |||
| 6f7dc6766d | |||
| 6cef8fa9f7 | |||
| 278c572e0f | |||
| 5615468042 | |||
| 4dc8bc6912 | |||
| 23fa4884bd | |||
| 772f2b75a1 | |||
| 1758694893 | |||
| 2de2c5f917 | |||
| 5c5af532bb | |||
| 03bf5dff7b | |||
| 10248b284a | |||
| 4b2bb8b655 | |||
| 2e5b57fe5d | |||
| bdbbe74021 | |||
| d155958be3 | |||
| 0df6008a3b | |||
| 145155d06e | |||
| 84f37a8b51 | |||
| 6ff350fb1c | |||
| c492d30b1c | |||
| 23360f4cbc | |||
| d3e3fd25e7 | |||
| 46daabc34f | |||
| ece864329e | |||
| 6cf34ea2ed | |||
| 1ee5e6ba9b | |||
| 1be16af5a4 | |||
| a808789acb | |||
| 406b666486 | |||
| 77d93602d6 | |||
| c5f4daa2e6 | |||
| caaedfdff5 | |||
| 6792688740 | |||
| 318b1469e3 | |||
| b6b101be3a | |||
| 5d4662342f | |||
| 68605ffc05 | |||
| 13af787c13 | |||
| 481e395624 | |||
| 202a26847b | |||
| 7b38e238f5 | |||
| 9820d75212 | |||
| 9c0c6eae15 | |||
| 1daa6869cc | |||
| aefa46e0c4 | |||
| ed55ab068b | |||
| 765e6feea6 | |||
| 007036301d | |||
| 1db4015003 | |||
| 8dac5a7448 | |||
| 4560ffb84b | |||
| 5be922613f | |||
| b26a21820b | |||
| a56f72b186 | |||
| 1eb1c18bdf | |||
| eea1a40265 | |||
| ebc692789c | |||
| 983ec77dbd | |||
| 25cf65f4cf | |||
| 2d3a697f22 | |||
| a4df3a651d | |||
| 3d9c48f40f | |||
| 4093267984 | |||
| 236609341f | |||
| 20ce945e73 | |||
| f2f424a565 | |||
| 3a6bb1c783 | |||
| 785ffd85a3 | |||
| 71da4af64b | |||
| 4d5711c304 | |||
| 1a4bb32c6c | |||
| 13a526d6be | |||
| babdb9e390 | |||
| 57c9ea600b | |||
| afffef78bd | |||
| 720f008050 | |||
| cd1d3241bd | |||
| e2d88313cf | |||
| 256d79a270 | |||
| d8d5a1e48e | |||
| 330cfa9dc5 | |||
| 8485f24342 | |||
| 51c4db5115 | |||
| fd33c86157 | |||
| ac7673805e | |||
| 428e217b56 | |||
| c0ba29aca8 | |||
| 81a72d466b | |||
| 942b33f9ce | |||
| 60e04cfd0a | |||
| 672b953ef5 | |||
| 1b481f2e2c | |||
| 4a027d6245 | |||
| 8af19f875c | |||
| b56e4060bf | |||
| 9b5d0475f5 | |||
| 53e11fe9ad | |||
| 9757658c34 | |||
| c82378128a | |||
| f95505704a | |||
| 6cdc9b04d0 | |||
| bad73529ae | |||
| 288baf41d3 | |||
| 7d1dcf3e1c | |||
| 2002c1fcad | |||
| 4abe81f916 | |||
| 571b03743f | |||
| 7fc1cad305 | |||
| 03a1dd75c9 | |||
| 02d8312d1b | |||
| c508fcc8d5 | |||
| 3af3708020 | |||
| 3098b7ad40 | |||
| d10bda3321 | |||
| 12214bade1 | |||
| d104b7b936 | |||
| 80110ac111 | |||
| 3bd1f63833 | |||
| 93f0fa0a47 | |||
| ee1de178b1 | |||
| 014d659908 | |||
| 113febad3f | |||
| 18a3a524f2 | |||
| 659fc6e274 | |||
| 89af9fa14c | |||
| d60535ae64 | |||
| b9ef947feb | |||
| e7ab83c5fb | |||
| dc81cd7cc8 | |||
| 268b3d54d7 | |||
| 1cfe7c6c6e | |||
| f0da0c02b4 | |||
| 2f8a65388c | |||
| 9978622960 | |||
| 35e5fc1503 | |||
| 2338ba5197 | |||
| e67eedbc93 | |||
| d812aca832 | |||
| 4315f36c6a | |||
| 10467835ac | |||
| f26d58504e | |||
| 1c256bba7a | |||
| cd4dc6efd2 | |||
| 2e6b71ac97 | |||
| 5b4f84bad7 | |||
| 847bb9bd0f | |||
| ebcaf44b63 | |||
| 17b05f9a13 |
@@ -0,0 +1,3 @@
|
||||
[submodule "source/packages/MokoSuiteClient"]
|
||||
path = source/packages/MokoSuiteClient
|
||||
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
|
||||
@@ -60,7 +60,7 @@ Joomla **package** with four sub-extensions:
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/mokoplatform/wiki/Home)
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,7 +34,8 @@ jobs:
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request') &&
|
||||
!startsWith(github.event.repository.name, 'Template-')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -52,7 +53,7 @@ jobs:
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
"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"
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/auto-release.yml
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +=======================================================================+
|
||||
@@ -52,7 +52,7 @@ on:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_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 }}
|
||||
|
||||
@@ -64,10 +64,14 @@ jobs:
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -75,6 +79,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -90,7 +95,7 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -99,18 +104,46 @@ jobs:
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
|
||||
# Resolve the source branch HEAD commit.
|
||||
SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
|
||||
|| { echo "::error::Source branch ${FROM} not found"; exit 1; }
|
||||
SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
|
||||
[ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
|
||||
|
||||
# Point rc at the source commit. If rc already exists (a protected branch that
|
||||
# cannot be deleted), force-update its ref in place instead of delete+recreate:
|
||||
# deleting a protected branch fails, which then makes the recreate return HTTP 409.
|
||||
if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then
|
||||
echo "rc exists - force-updating to ${FROM} (${SRC_SHA})"
|
||||
curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \
|
||||
|| { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; }
|
||||
else
|
||||
echo "Creating rc from ${FROM}"
|
||||
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \
|
||||
|| { echo "::error::Failed to create rc from ${FROM}"; exit 1; }
|
||||
fi
|
||||
|
||||
# Repoint the PR at rc, then delete the old source branch (non-fatal).
|
||||
if [ -n "$PR" ]; then
|
||||
curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
|
||||
fi
|
||||
curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true
|
||||
echo "Renamed ${FROM} -> rc"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "mokogitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
@@ -121,7 +154,7 @@ jobs:
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
@@ -163,9 +196,13 @@ jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -173,11 +210,12 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "mokogitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
@@ -208,7 +246,7 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -269,7 +307,7 @@ jobs:
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
@@ -294,7 +332,7 @@ jobs:
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
@@ -363,7 +401,7 @@ jobs:
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
@@ -388,11 +426,11 @@ jobs:
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
- name: "Step 11: Delete rc branch (dev reset moved to cascade-dev.yml)"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
@@ -400,23 +438,15 @@ jobs:
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
# dev is reset from main by the dedicated "Cascade Main -> Dev" workflow
|
||||
# (cascade-dev.yml), which runs after this release completes.
|
||||
echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
@@ -437,7 +467,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
@@ -463,5 +493,5 @@ jobs:
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
@@ -33,7 +33,8 @@ jobs:
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
# URL-encode the branch name's slashes (no PHP dependency on the runner)
|
||||
ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
|
||||
@@ -1,10 +1,106 @@
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.Cascade
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/cascade-dev.yml
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Cascade main -> dev via PR; auto-merge only if conflict-free, else notify
|
||||
|
||||
name: "Cascade Main -> Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
# ntfy destination is configured via repo or org variables (org vars are inherited).
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.CASCADE_NTFY_TOPIC || vars.NTFY_TOPIC || 'gitea-releases' }}
|
||||
|
||||
jobs:
|
||||
noop:
|
||||
cascade:
|
||||
name: Cascade main -> dev
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
- name: Open main -> dev PR (auto-merge if clean, else notify)
|
||||
env:
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${REPO}"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
jqnum() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null; }
|
||||
|
||||
# 0. dev must exist
|
||||
if ! curl -sf -H "$AUTH" "${API}/branches/dev" >/dev/null 2>&1; then
|
||||
echo "No dev branch - nothing to cascade."; exit 0
|
||||
fi
|
||||
|
||||
# 1. is main ahead of dev?
|
||||
AHEAD=$(curl -sf -H "$AUTH" "${API}/compare/dev...main" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('total_commits',0))" 2>/dev/null || echo 0)
|
||||
if [ "${AHEAD:-0}" -eq 0 ]; then
|
||||
echo "dev already up to date with main."; exit 0
|
||||
fi
|
||||
echo "main is ${AHEAD} commit(s) ahead of dev."
|
||||
|
||||
# 2. reuse an open main->dev PR, else create one
|
||||
PR=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=dev" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='main'), ''))" 2>/dev/null || echo "")
|
||||
if [ -z "$PR" ]; then
|
||||
RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
|
||||
-d '{"head":"main","base":"dev","title":"chore(sync): cascade main -> dev","body":"Automated cascade of main into dev. Auto-merges only if conflict-free; otherwise left open for manual resolution."}')
|
||||
PR=$(printf '%s' "$RESP" | jqnum number)
|
||||
if [ -z "$PR" ]; then
|
||||
echo "::warning::Could not open cascade PR: $RESP"; exit 0
|
||||
fi
|
||||
echo "Opened cascade PR #${PR}"
|
||||
else
|
||||
echo "Reusing open cascade PR #${PR}"
|
||||
fi
|
||||
|
||||
# 3. wait for MokoGitea to compute mergeability (conflict detection)
|
||||
MERGEABLE=""
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
MERGEABLE=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqnum mergeable)
|
||||
case "$MERGEABLE" in True|False) break ;; esac
|
||||
sleep 3
|
||||
done
|
||||
echo "mergeable=${MERGEABLE}"
|
||||
|
||||
notify() {
|
||||
curl -sS \
|
||||
-H "Title: ${REPO}: dev cascade needs manual merge" \
|
||||
-H "Tags: warning,twisted_rightwards_arrows" \
|
||||
-H "Priority: high" \
|
||||
-H "Click: ${MOKOGITEA_URL}/${REPO}/pulls/${PR}" \
|
||||
-d "main -> dev cascade PR #${PR} $1 It was NOT auto-merged; resolve it manually." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
}
|
||||
|
||||
# 4. auto-merge only if conflict-free; otherwise notify
|
||||
if [ "$MERGEABLE" = "True" ]; then
|
||||
CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true}')
|
||||
if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then
|
||||
echo "Cascade PR #${PR} merged (or scheduled to merge when checks pass)."
|
||||
else
|
||||
echo "::warning::Auto-merge returned HTTP ${CODE}: $(cat /tmp/merge.json)"
|
||||
notify "could not be auto-merged (HTTP ${CODE})."
|
||||
fi
|
||||
else
|
||||
echo "::warning::Cascade PR #${PR} has conflicts (mergeable=${MERGEABLE}); sending notification."
|
||||
notify "has conflicts and cannot be merged automatically."
|
||||
fi
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.gitea/workflows/ci-generic.yml
|
||||
# PATH: /.mokogitea/workflows/ci-generic.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -26,6 +32,8 @@ jobs:
|
||||
lint:
|
||||
name: Lint & Validate
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
|
||||
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -124,6 +132,9 @@ jobs:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
# Run only when lint succeeded; always() forces evaluation so a skipped
|
||||
# lint (e.g. template repos) skips this job cleanly instead of hanging.
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Reusable workflow — creates/updates a MokoGitea issue when a CI gate fails.
|
||||
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||
|
||||
name: "Universal: CI Issue Reporter"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
gate:
|
||||
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||
required: true
|
||||
type: string
|
||||
details:
|
||||
description: "Human-readable failure description"
|
||||
required: true
|
||||
type: string
|
||||
severity:
|
||||
description: "error or warning"
|
||||
required: false
|
||||
type: string
|
||||
default: "error"
|
||||
workflow:
|
||||
description: "Workflow name for the issue title"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
secrets:
|
||||
MOKOGITEA_TOKEN:
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: "Report: ${{ inputs.gate }}"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone MokoCLI
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||
|
||||
- name: Report CI failure
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||
--gate "${{ inputs.gate }}" \
|
||||
--details "${{ inputs.details }}" \
|
||||
--severity "${{ inputs.severity }}" \
|
||||
--workflow "${{ inputs.workflow }}"
|
||||
@@ -3,10 +3,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# PATH: /.mokogitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
@@ -33,30 +33,30 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
for BRANCH in $BRANCHES; do
|
||||
# Skip protected branches
|
||||
case "$BRANCH" in
|
||||
main|master|develop|release/*|hotfix/*) continue ;;
|
||||
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
|
||||
esac
|
||||
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/gitleaks.yml.template
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /.mokogitea/workflows/gitleaks.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||
#
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.43.16
|
||||
# VERSION: 02.56.13
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: MokoStandards.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# PATH: /.mokogitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Universal: Build & Release"
|
||||
- "Joomla: Extension CI"
|
||||
- "Generic: Project CI"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/pr-check.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
@@ -47,15 +47,15 @@ jobs:
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
@@ -86,7 +86,8 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -96,6 +97,80 @@ jobs:
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Docs Update Gate (main PRs) ─────────────────────────────────────────
|
||||
require-docs:
|
||||
name: Require Docs Update
|
||||
runs-on: ubuntu-latest
|
||||
# Enforce only on PRs merging into main: README.md + CHANGELOG.md must both be updated.
|
||||
if: ${{ github.base_ref == 'main' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require README.md and CHANGELOG.md in the PR diff
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || true)"
|
||||
if [ -z "$CHANGED" ]; then
|
||||
git fetch -q origin "${{ github.base_ref }}" 2>/dev/null || true
|
||||
CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" 2>/dev/null || true)"
|
||||
fi
|
||||
echo "Changed files in PR:"
|
||||
echo "$CHANGED"
|
||||
MISSING=""
|
||||
echo "$CHANGED" | grep -qxE 'README\.md' || MISSING="README.md"
|
||||
echo "$CHANGED" | grep -qxE 'CHANGELOG\.md' || MISSING="${MISSING:+$MISSING, }CHANGELOG.md"
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::PRs into main must update: ${MISSING}"
|
||||
{
|
||||
echo "## Docs Update Required"
|
||||
echo ""
|
||||
echo "PRs merging into \`main\` must update both **README.md** and **CHANGELOG.md**."
|
||||
echo ""
|
||||
echo "Not updated in this PR: **${MISSING}**"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
echo "Docs update present (README.md + CHANGELOG.md)"
|
||||
echo "## Docs Update: Passed" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── Wiki Update Reminder (main PRs, non-blocking) ───────────────────────
|
||||
wiki-reminder:
|
||||
name: Wiki Update Reminder
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.base_ref == 'main' }}
|
||||
steps:
|
||||
- name: Remind to update the wiki
|
||||
env:
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
{
|
||||
echo "## Wiki Update Reminder"
|
||||
echo ""
|
||||
echo "Docs are **wiki-first** at MokoConsulting. If this change affects behavior, usage, configuration, or standards, update the repo wiki:"
|
||||
echo ""
|
||||
echo "- ${SERVER}/${REPO}/wiki"
|
||||
echo ""
|
||||
echo "_Non-blocking reminder._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Post a single PR comment (idempotent via hidden marker); best-effort, never fails.
|
||||
API="${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments"
|
||||
if [ -n "${TOKEN:-}" ] && [ -n "${PR:-}" ]; then
|
||||
existing="$(curl -sf -H "Authorization: token ${TOKEN}" "$API" 2>/dev/null | grep -c 'wiki-reminder' || true)"
|
||||
if [ "${existing:-0}" -eq 0 ]; then
|
||||
curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -X POST "$API" \
|
||||
-d '{"body":"<!-- wiki-reminder -->\n\n**Wiki reminder:** docs are wiki-first -- if this PR changes behavior, usage, config, or standards, please update the repo wiki before/after merge. _(non-blocking)_"}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
echo "Wiki reminder emitted (non-blocking)."
|
||||
|
||||
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||
gitleaks:
|
||||
name: Secret Scan
|
||||
@@ -126,6 +201,8 @@ jobs:
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
|
||||
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -147,11 +224,12 @@ jobs:
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
|
||||
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
|
||||
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected platform: $PLATFORM"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
@@ -272,7 +350,7 @@ jobs:
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
echo "::warning::No Joomla manifest found (MokoSuite site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
@@ -285,7 +363,7 @@ jobs:
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the MokoGitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
@@ -492,43 +570,33 @@ jobs:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
# Run only when both gates succeeded; always() forces evaluation so a skipped
|
||||
# validate (e.g. template repos) skips this job cleanly instead of hanging.
|
||||
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||
with:
|
||||
gate: "PR Validation"
|
||||
workflow: "PR Check"
|
||||
severity: error
|
||||
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
secrets: inherit
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/pre-release.yml
|
||||
# VERSION: 05.02.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -48,9 +48,13 @@ jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -59,6 +63,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Update submodules to main
|
||||
run: |
|
||||
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -75,7 +84,7 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -147,8 +156,8 @@ jobs:
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "mokogitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||
@@ -25,16 +25,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == false &&
|
||||
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||
startsWith(github.event.pull_request.head.ref, 'rc/') &&
|
||||
!startsWith(github.event.repository.name, 'Template-')
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
env:
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
set -euo pipefail
|
||||
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
|
||||
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
|
||||
fi
|
||||
SUFFIX="${BRANCH#rc/}"
|
||||
DEV_BRANCH="dev/${SUFFIX}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
@@ -42,25 +51,22 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||
"${API}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "201" ]; then
|
||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.mokogitea/workflows/repo-health.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller|gitea-actions[bot])
|
||||
jmiller|mokogitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
@@ -671,42 +671,30 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
report-scripts:
|
||||
name: "Report: Scripts Governance"
|
||||
needs: [access_check, scripts_governance]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
needs.scripts_governance.result == 'failure'
|
||||
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||
with:
|
||||
gate: "Scripts Governance"
|
||||
workflow: "Repo Health"
|
||||
severity: error
|
||||
details: "Scripts directory policy violations detected. Review required and allowed directories."
|
||||
secrets: inherit
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
report-health:
|
||||
name: "Report: Repository Health"
|
||||
needs: [access_check, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.repo_health.result == 'failure'
|
||||
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||
with:
|
||||
gate: "Repository Health"
|
||||
workflow: "Repo Health"
|
||||
severity: error
|
||||
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Sync Workflows to Repos
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.mokogitea/workflows/**'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ startsWith(github.event.repository.name, 'Template-') }}
|
||||
steps:
|
||||
- name: Checkout mokocli
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: MokoConsulting/MokoCLI
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1
|
||||
with:
|
||||
php-version: '8.1'
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction
|
||||
|
||||
- name: Sync workflows to generic repos
|
||||
run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoGitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
# PATH: /.mokogitea/workflows/version-set.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||
|
||||
name: "Joomla: Set Version"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g. 01.00.00)"
|
||||
required: true
|
||||
type: string
|
||||
branch:
|
||||
description: "Branch to update (default: current)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
set-version:
|
||||
name: Set Version to ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update manifest version
|
||||
run: |
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 3 -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 "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||
else
|
||||
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||
fi
|
||||
|
||||
- name: Update README.md version
|
||||
run: |
|
||||
if [ -f "README.md" ]; then
|
||||
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||
echo "README.md version updated to ${VERSION}"
|
||||
else
|
||||
echo "::warning::No VERSION line found in README.md — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
# Check if this version already has an entry
|
||||
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||
else
|
||||
# Insert new version entry after [Unreleased] or at the top after header
|
||||
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
else
|
||||
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
fi
|
||||
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||
fi
|
||||
else
|
||||
echo "::warning::No CHANGELOG.md found — skipping"
|
||||
fi
|
||||
|
||||
- name: Update FILE INFORMATION blocks
|
||||
run: |
|
||||
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||
while IFS= read -r -d '' FILE; do
|
||||
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "Moko Consulting [bot]"
|
||||
git config user.email "hello@mokoconsulting.tech"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes detected — nothing to commit"
|
||||
else
|
||||
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||
|
||||
Authored-by: Moko Consulting"
|
||||
git push
|
||||
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -3,9 +3,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# DEFGROUP: MokoGitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# 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
|
||||
@@ -13,6 +13,7 @@
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
@@ -26,8 +27,10 @@ jobs:
|
||||
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]')
|
||||
startsWith(github.event.repository.name, 'Template-') &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')))
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
@@ -49,8 +52,14 @@ jobs:
|
||||
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
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
|
||||
- name: Install PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
+17
-148
@@ -1,158 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Customizable restore script filename per backup profile (reduces discoverability on remote servers)
|
||||
- MokoRestore standalone mode: multi-ZIP selector when multiple backup archives are present
|
||||
- MokoRestore preflight: Joomla installation detection warning before overwriting an existing site
|
||||
- MokoRestore error handling: try/catch on fetch calls, HTTP status checks, JSON parse recovery
|
||||
- Download button on individual backup record detail toolbar
|
||||
- Profile column in backup records list links to the profile edit view
|
||||
|
||||
### Changed
|
||||
- Moved download, browse archive, and view log actions from backup list rows into the individual backup record view
|
||||
- Removed "Run Backup" / "Backup Now" buttons from profiles list, profile edit toolbar, and backup records view (backups are triggered from the dashboard only)
|
||||
- Removed ordering field from profiles; default sort is now by ID ascending
|
||||
- MokoRestore cleanup and security messages now reference the actual script filename instead of hardcoded "restore.php"
|
||||
## [02.56.11] --- 2026-07-05
|
||||
|
||||
### Fixed
|
||||
- Bootstrap 5 modal conversion for snapshots view (data-bs-dismiss, modal-footer, getOrCreateInstance)
|
||||
- ntfy default URL changed from ntfy.sh to ntfy.mokoconsulting.tech
|
||||
- Untranslated JFIELD_ORDERING_ASC / JFIELD_ORDERING_LABEL language keys replaced with component-specific keys
|
||||
- Options page title now shows "MokoSuiteBackup Options" instead of raw language key
|
||||
- Profile dropdown IDs in backup records and dashboard show "#ID — Title (type)" format
|
||||
- MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state
|
||||
- Package `postflight` now removes the orphaned `com_component-mokosuitebackup` registration (stray extension record, admin menu items, schema rows, and `administrator/` folder) left behind by the old "Type - Name" component `<name>` — self-healing on update so affected sites reconcile to the real `com_mokosuitebackup`. (#213 follow-up)
|
||||
|
||||
## [01.43.00] --- 2026-06-24
|
||||
|
||||
|
||||
## [01.43.00] --- 2026-06-24
|
||||
|
||||
## [01.42.00] --- 2026-06-23
|
||||
|
||||
|
||||
## [01.42.00] --- 2026-06-23
|
||||
|
||||
## [01.41.00] — 2026-06-23
|
||||
|
||||
### Added — Multi-Remote Storage
|
||||
- New `#__mokosuitebackup_remotes` table for multiple destinations per profile
|
||||
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit
|
||||
- Engine uploads to ALL enabled destinations (BackupEngine + SteppedBackupEngine)
|
||||
- Migration auto-converts existing SFTP/S3/GDrive/FTP profile columns to new table
|
||||
- Backward compatibility: falls back to legacy single-remote columns if table empty
|
||||
- Secrets masked in API responses, merged from DB on save
|
||||
|
||||
### Added — Content Snapshots
|
||||
- Lightweight JSON snapshots of articles, categories, and modules
|
||||
- Includes tags, custom fields, workflow associations, field values
|
||||
- Restore modes: Replace (clean slate), Merge (upsert), Selective (per-article)
|
||||
- Snapshot retention: max count + max age with automatic cleanup
|
||||
- Scheduled snapshot task via com_scheduler
|
||||
- CLI: `mokosuitebackup:snapshot create|restore|list|delete`
|
||||
- REST API: create, list, restore, delete, download snapshots
|
||||
- Tabbed browse modal: Articles / Categories / Modules with item counts
|
||||
|
||||
### Added — SFTP Remote Storage
|
||||
- SFTP support with SSH key file authentication (key stored base64 in database)
|
||||
- Auth type dropdown: Password / Key File / Key File + Passphrase
|
||||
- SshKeyField: file upload via FileReader, key never exposed in HTML
|
||||
- SFTP remote directory browser for path selection
|
||||
- `__KEEP_EXISTING__` sentinel preserves key on profile re-save
|
||||
|
||||
### Added — MokoRestore Wizard (9 steps)
|
||||
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
|
||||
- Preset buttons: "All Replace", "All Skip", "Everything except users"
|
||||
- Post-restore actions: reset passwords, hits, versions, sessions, cache
|
||||
- Auto-detect sanitized passwords and prompt for reset (random temp password)
|
||||
- Standalone mode: restore.php scans directory for ZIP files
|
||||
- Wrapped mode: restore.php bundled inside backup ZIP
|
||||
- Security gate with filesystem verification + path traversal protection
|
||||
|
||||
### Added — Data Sanitization
|
||||
- Sanitize user passwords: replace hashes with invalid sentinel
|
||||
- Sanitize user emails: replace with dummy values
|
||||
- Clear session data: exclude `#__session` table
|
||||
- Preserve super admin credentials (optional)
|
||||
- GDPR-friendly backup sharing for demos and staging sites
|
||||
|
||||
### Added — Backup Engine
|
||||
- Pre-flight validation: directory, disk space, extensions, credentials, running backups
|
||||
- Auto-verify archive integrity after creation (ZIP, tar.gz, 7z)
|
||||
- 7z archive format via system 7za/7z CLI binary with native encryption
|
||||
- Streaming database dump to temp file (prevents OOM on large sites)
|
||||
- S3 streaming upload via CURLOPT_PUT (prevents OOM)
|
||||
- Graceful remote degradation: local backup preserved if upload fails
|
||||
- DatabaseDumper::dumpToFile() for memory-efficient operation
|
||||
|
||||
### Added — Admin UI
|
||||
- Dashboard: snapshot widget, 30-day backup trend chart, per-profile storage breakdown
|
||||
- CPanel admin dashboard module (mod_mokosuitebackup_cpanel) with quick actions
|
||||
- Backup type filter dropdown in backups list
|
||||
- Backup comparison: select two backups for side-by-side diff
|
||||
- Archive browser: view files inside backup without extracting
|
||||
- Manual purge: delete backups older than a date with count preview
|
||||
- Backup count badges on profile list
|
||||
- "Do not navigate away" warning in backup/restore progress modals
|
||||
- Clickable placeholder pills for backup directory and archive name fields
|
||||
- Comprehensive help modal with absolute/relative/placeholder path documentation
|
||||
- Placeholder resolution display with EXAMPLE prefix
|
||||
- All placeholders UPPERCASE: [HOST], [SITE_NAME], [DATE], [DATETIME], etc.
|
||||
|
||||
### Added — CLI & API
|
||||
- `mokosuitebackup:restore` with --files-only, --db-only, --password options
|
||||
- `mokosuitebackup:snapshot` with create, restore, list, delete actions
|
||||
- REST API for snapshots: create, list, restore, delete, download
|
||||
- Profile credentials masked in API responses
|
||||
|
||||
### Added — Notifications & Logging
|
||||
- Email/ntfy notifications for site restore, snapshot create/restore
|
||||
- Joomla Action Logs for restore, snapshot, and snapshot restore events
|
||||
- Global ntfy server/topic/token settings (fallback for profiles)
|
||||
|
||||
### Added — Security & Configuration
|
||||
- Webcron secret field with CSPRNG generator + strength meter
|
||||
- IP whitelist field with current IP detection + one-click "Add my IP"
|
||||
- 10 ACL permissions with full enforcement audit across all controllers
|
||||
- Config defaults: archive format, MokoRestore mode, sanitization settings
|
||||
- Path traversal protection on all archive extraction (ZIP, tar.gz, JPA)
|
||||
## [02.56.08] --- 2026-07-05
|
||||
|
||||
### Fixed
|
||||
- CLI RestoreCommand passed wrong arguments (filepath instead of record ID)
|
||||
- JPA path traversal: reject `../` in archive entry paths
|
||||
- S3Uploader OOM: streaming upload instead of file_get_contents
|
||||
- DatabaseDumper OOM: streaming to file instead of in-memory string
|
||||
- AkeebaImporter: removed unserialize() (PHP object injection risk)
|
||||
- BackupTable: delete DB row before file (prevents data loss)
|
||||
- RestoreEngine: staging path sanitized with preg_replace
|
||||
- API profiles: sensitive fields masked with `***`
|
||||
- Webcron: missing return after sendJsonResponse on auth failure
|
||||
- loadFormData(): cast array to object (PHP 8.x TypeError fix)
|
||||
- MokoRestore data-only mode: uses REPLACE INTO for existing rows
|
||||
- Plaintext archive deleted on encryption failure
|
||||
- TarGzArchiver: intermediate .tar cleaned in finally block
|
||||
- Install script: single-line comments converted to block comments
|
||||
- Orphaned root-level webservices plugin files removed
|
||||
- include_mokorestore column: TINYINT changed to VARCHAR(20)
|
||||
- Snapshot fields_values: scoped dump and restore to com_content.article (previously destroyed values for contacts, users, etc.)
|
||||
- Run Backup button: accept CSRF token from GET (fixes "token did not match" on profile edit)
|
||||
- SFTP fields: moved into remote fieldset for showon visibility; removed required attr that blocked non-SFTP saves
|
||||
- Script.php merge conflict markers resolved
|
||||
- Component manifest `<name>` reverted to the element-safe `MokoSuiteBackup`. The "Type - Name" convention derived element `com_component-mokosuitebackup`, registering a duplicate component and leaving the real `com_mokosuitebackup` orphaned on the old version. (#213)
|
||||
|
||||
## [01.24.00] — 2026-06-02
|
||||
## [02.56.07] --- 2026-07-05
|
||||
|
||||
### Added
|
||||
- Initial release: full-site backup and restore for Joomla 6
|
||||
- Database, files, and configuration backup
|
||||
- ZIP and tar.gz archive formats with AES-256 encryption
|
||||
- Differential backups based on file manifests
|
||||
- FTP/FTPS, S3, Google Drive remote storage
|
||||
- MokoRestore standalone restore wizard
|
||||
- CLI backup and restore commands
|
||||
- REST API for remote management
|
||||
- Scheduled tasks via com_scheduler
|
||||
- Email and ntfy push notifications
|
||||
- Per-profile retention, exclusions, and notifications
|
||||
- Akeeba Backup migration tool
|
||||
- Admin dashboard with system health checks
|
||||
### Security
|
||||
- Mask the FTP password in `AjaxController::maskSecrets()`/`mergeExistingSecrets()` — stored FTP remotes no longer leak their password via `listRemotes`. (#169)
|
||||
- Stop the API single-item view (`Backups/JsonapiView`) leaking the server `absolute_path`. (#187)
|
||||
- SFTP uploader uses `StrictHostKeyChecking=accept-new` instead of `no`. (#182)
|
||||
|
||||
### Fixed
|
||||
- `DatabaseDumper` writes a real newline after `DROP TABLE` (was a literal `\n`). (#179)
|
||||
- Profile-picker forms order by `id` instead of the dropped `ordering` column. (#180)
|
||||
- `uninstall.mysql.sql` now drops the snapshots table. (#181)
|
||||
- CLI snapshot delete uses the `data_file` column (was non-existent `file_path`). (#184)
|
||||
- Remove the duplicate pre-update backup (content plugin no longer subscribes to `onExtensionBeforeUpdate`). (#186)
|
||||
- `DatabaseImporter` collects non-fatal statement errors (`getErrors()`/`hasErrors()`). (#188)
|
||||
|
||||
@@ -5,7 +5,7 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokosuitebackup` |
|
||||
| **Type** | Joomla Package (8 sub-extensions) |
|
||||
| **Type** | Joomla Package (9 sub-extensions + MokoSuiteClient) |
|
||||
| **Joomla** | 6.x+ |
|
||||
| **PHP** | 8.1+ |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
@@ -19,6 +19,7 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
- Stepped AJAX engine prevents timeout on shared hosting
|
||||
- AES-256 ZIP encryption with configurable password
|
||||
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
|
||||
- Per-profile retention — configure max backup count and max age (days) per profile, with global defaults
|
||||
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
|
||||
|
||||
### Content Snapshots
|
||||
@@ -30,7 +31,8 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
- Scheduled snapshot task via com_scheduler
|
||||
|
||||
### Remote Storage
|
||||
- SFTP with SSH key file authentication (key stored base64-encoded in database)
|
||||
- Multi-remote — upload to multiple destinations per profile simultaneously
|
||||
- SFTP with SSH key file auth + remote directory browser
|
||||
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
|
||||
- Google Drive with OAuth2 and resumable uploads
|
||||
- Graceful degradation — local backup preserved if upload fails
|
||||
@@ -66,6 +68,10 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
- Snapshots: create, list, restore, delete, download
|
||||
- Profile credentials masked in API responses
|
||||
|
||||
### Bundled: MokoSuiteClient
|
||||
- Full MokoSuiteClient package installed automatically alongside MokoSuiteBackup
|
||||
- Provides admin dashboard, security firewall, tenant management, and developer tools
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.56.13
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
# Security Policy
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the following versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 01.x.x | :white_check_mark: |
|
||||
| < 01.0 | :x: |
|
||||
|
||||
Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
### Where to Report
|
||||
|
||||
**DO NOT** create public GitHub issues for security vulnerabilities.
|
||||
|
||||
Report security vulnerabilities privately to:
|
||||
|
||||
**Email**: `security@mokoconsulting.tech`
|
||||
|
||||
**Subject Line**: `[SECURITY] Template-Joomla - Brief Description`
|
||||
|
||||
### What to Include
|
||||
|
||||
A complete vulnerability report should include:
|
||||
|
||||
1. **Description**: Clear explanation of the vulnerability
|
||||
2. **Impact**: Potential security impact and severity assessment
|
||||
3. **Affected Versions**: Which versions are vulnerable
|
||||
4. **Reproduction Steps**: Detailed steps to reproduce the issue
|
||||
5. **Proof of Concept**: Code, configuration, or demonstration (if applicable)
|
||||
6. **Suggested Fix**: Proposed remediation (if known)
|
||||
7. **Disclosure Timeline**: Your expectations for public disclosure
|
||||
|
||||
### Response Timeline
|
||||
|
||||
* **Initial Response**: Within 3 business days
|
||||
* **Assessment Complete**: Within 7 business days
|
||||
* **Fix Timeline**: Depends on severity (see below)
|
||||
* **Disclosure**: Coordinated with reporter
|
||||
|
||||
## Severity Classification
|
||||
|
||||
Vulnerabilities are classified using the following severity levels:
|
||||
|
||||
### Critical
|
||||
* Remote code execution
|
||||
* Authentication bypass
|
||||
* Data breach or exposure of sensitive information
|
||||
* **Fix Timeline**: 7 days
|
||||
|
||||
### High
|
||||
* Privilege escalation
|
||||
* SQL injection or command injection
|
||||
* Cross-site scripting (XSS) with significant impact
|
||||
* **Fix Timeline**: 14 days
|
||||
|
||||
### Medium
|
||||
* Information disclosure (limited scope)
|
||||
* Denial of service
|
||||
* Security misconfigurations with moderate impact
|
||||
* **Fix Timeline**: 30 days
|
||||
|
||||
### Low
|
||||
* Security best practice violations
|
||||
* Minor information leaks
|
||||
* Issues requiring user interaction or complex preconditions
|
||||
* **Fix Timeline**: 60 days or next release
|
||||
|
||||
## Remediation Process
|
||||
|
||||
1. **Acknowledgment**: Security team confirms receipt and begins investigation
|
||||
2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed
|
||||
3. **Development**: Security patch is developed and tested
|
||||
4. **Review**: Patch undergoes security review and validation
|
||||
5. **Release**: Fixed version is released with security advisory
|
||||
6. **Disclosure**: Public disclosure follows coordinated timeline
|
||||
|
||||
## Security Advisories
|
||||
|
||||
Security advisories are published via:
|
||||
|
||||
* GitHub Security Advisories
|
||||
* Release notes and CHANGELOG.md
|
||||
* Email notification to project users (if mailing list is established)
|
||||
|
||||
Advisories include:
|
||||
|
||||
* CVE identifier (if applicable)
|
||||
* Severity rating
|
||||
* Affected versions
|
||||
* Fixed versions
|
||||
* Mitigation steps
|
||||
* Attribution (with reporter consent)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
For projects using this template:
|
||||
|
||||
### Required Controls
|
||||
|
||||
* Enable GitHub security features (Dependabot, code scanning)
|
||||
* Implement branch protection on `main`
|
||||
* Require code review for all changes
|
||||
* Enforce signed commits (recommended)
|
||||
* Use secrets management (never commit credentials)
|
||||
* Maintain security documentation
|
||||
* Follow secure coding standards defined in MokoStandards
|
||||
|
||||
### Joomla Plugin Security
|
||||
|
||||
* Follow Joomla security best practices
|
||||
* Validate and sanitize all user input
|
||||
* Use Joomla's database API to prevent SQL injection
|
||||
* Properly escape output to prevent XSS
|
||||
* Implement proper access control checks
|
||||
* Use Joomla's session and authentication APIs
|
||||
* Keep Joomla and dependencies up to date
|
||||
|
||||
### CI/CD Security
|
||||
|
||||
* Validate all inputs
|
||||
* Sanitize outputs
|
||||
* Use least privilege access
|
||||
* Pin dependencies with hash verification
|
||||
* Scan for vulnerabilities in dependencies
|
||||
* Audit third-party actions and tools
|
||||
|
||||
#### Automated Security Scanning
|
||||
|
||||
All repositories SHOULD implement:
|
||||
|
||||
**CodeQL Analysis**:
|
||||
* Enabled for PHP and other supported languages
|
||||
* Runs on: push to main, pull requests, weekly schedule
|
||||
* Query sets: `security-extended` and `security-and-quality`
|
||||
* Configuration: `.github/workflows/codeql-analysis.yml`
|
||||
|
||||
**Dependabot Security Updates**:
|
||||
* Weekly scans for vulnerable dependencies
|
||||
* Automated pull requests for security patches
|
||||
* Configuration: `.github/dependabot.yml`
|
||||
|
||||
**Secret Scanning**:
|
||||
* Enabled by default with push protection
|
||||
* Prevents accidental credential commits
|
||||
|
||||
### Dependency Management
|
||||
|
||||
* Keep dependencies up to date
|
||||
* Monitor security advisories for dependencies
|
||||
* Remove unused dependencies
|
||||
* Audit new dependencies before adoption
|
||||
* Document security-critical dependencies
|
||||
|
||||
## Compliance and Governance
|
||||
|
||||
This security policy is aligned with MokoStandards. Deviations require documented justification.
|
||||
|
||||
Security policies are reviewed and updated at least annually or following significant security incidents.
|
||||
|
||||
## Attribution and Recognition
|
||||
|
||||
We acknowledge and appreciate responsible disclosure. With your permission, we will:
|
||||
|
||||
* Credit you in security advisories
|
||||
* List you in CHANGELOG.md for the fix release
|
||||
* Recognize your contribution publicly (if desired)
|
||||
|
||||
## Contact and Escalation
|
||||
|
||||
* **Security Team**: security@mokoconsulting.tech
|
||||
* **Primary Contact**: hello@mokoconsulting.tech
|
||||
* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are explicitly out of scope:
|
||||
|
||||
* Issues in third-party dependencies (report directly to maintainers)
|
||||
* Social engineering attacks
|
||||
* Physical security issues
|
||||
* Denial of service via resource exhaustion without amplification
|
||||
* Issues requiring physical access to systems
|
||||
* Theoretical vulnerabilities without proof of exploitability
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Document | Security Policy |
|
||||
| Path | /SECURITY.md |
|
||||
| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) |
|
||||
| Owner | Moko Consulting |
|
||||
| Scope | Security vulnerability handling |
|
||||
| Status | Active |
|
||||
| Effective | 2026-01-16 |
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Change Description | Author |
|
||||
| ---------- | ------------------------------------------------- | --------------- |
|
||||
| 2026-01-16 | Initial creation for template repository | Moko Consulting |
|
||||
@@ -1,37 +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;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\WebServices\MokoSuiteBackup\Extension\MokoSuiteBackupWebServices;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new MokoSuiteBackupWebServices(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('webservices', 'mokosuitebackup')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
Submodule
+1
Submodule source/packages/MokoSuiteClient added at 2273690f94
@@ -15,5 +15,6 @@
|
||||
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
|
||||
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
|
||||
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
||||
<action name="mokosuitebackup.backup.cancel" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -24,7 +24,6 @@ class JsonapiView extends BaseApiView
|
||||
'origin',
|
||||
'backup_type',
|
||||
'archivename',
|
||||
'absolute_path',
|
||||
'total_size',
|
||||
'db_size',
|
||||
'files_count',
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
type="sql"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
|
||||
query="SELECT id AS value, CONCAT(title, ' (#', id, ')') 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 id ASC"
|
||||
default="1"
|
||||
>
|
||||
<option value="1">Default Backup Profile (#1)</option>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
>
|
||||
<option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option>
|
||||
<option value="complete">COM_MOKOJOOMBACKUP_STATUS_COMPLETE</option>
|
||||
<option value="warning">COM_MOKOJOOMBACKUP_STATUS_WARNING</option>
|
||||
<option value="running">COM_MOKOJOOMBACKUP_STATUS_RUNNING</option>
|
||||
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
|
||||
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
|
||||
|
||||
@@ -206,25 +206,6 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||
<field
|
||||
name="remote_legacy_note"
|
||||
type="note"
|
||||
label=""
|
||||
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
|
||||
class="alert alert-info small"
|
||||
/>
|
||||
<field
|
||||
name="remote_storage"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE_DESC"
|
||||
default="none"
|
||||
>
|
||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||
</field>
|
||||
<field
|
||||
name="remote_keep_local"
|
||||
type="radio"
|
||||
@@ -236,81 +217,6 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<!-- SFTP fields (shown when remote_storage = sftp) -->
|
||||
<field
|
||||
name="sftp_host"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_port"
|
||||
type="number"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
|
||||
default="22"
|
||||
min="1"
|
||||
max="65535"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_username"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_auth_type"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
|
||||
default="key"
|
||||
showon="remote_storage:sftp"
|
||||
>
|
||||
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
|
||||
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
|
||||
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
|
||||
</field>
|
||||
<field
|
||||
name="sftp_password"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:password"
|
||||
/>
|
||||
<field
|
||||
name="sftp_key_data"
|
||||
type="SshKey"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
||||
filter="raw"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="sftp_passphrase"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
|
||||
/>
|
||||
<field
|
||||
name="sftp_path"
|
||||
type="SftpPath"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:sftp"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
||||
@@ -408,157 +314,4 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ftp" label="COM_MOKOJOOMBACKUP_FIELDSET_FTP">
|
||||
<field
|
||||
name="ftp_host"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_port"
|
||||
type="number"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT_DESC"
|
||||
default="21"
|
||||
min="1"
|
||||
max="65535"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_username"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_USERNAME"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_password"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSWORD"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_path"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_passive"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="remote_storage:ftp"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="ftp_ssl"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
showon="remote_storage:ftp"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
|
||||
<field
|
||||
name="gdrive_client_id"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_client_secret"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_refresh_token"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC"
|
||||
maxlength="512"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_folder_id"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="s3" label="COM_MOKOJOOMBACKUP_FIELDSET_S3">
|
||||
<field
|
||||
name="s3_endpoint"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC"
|
||||
maxlength="512"
|
||||
hint="https://s3.amazonaws.com"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_region"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_REGION"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_S3_REGION_DESC"
|
||||
default="us-east-1"
|
||||
maxlength="50"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_access_key"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_secret_key"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_bucket"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_path"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_S3_PATH"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_S3_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||
COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||
|
||||
@@ -22,7 +23,7 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
|
||||
|
||||
; Dashboard view
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
|
||||
@@ -42,14 +43,16 @@ COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
|
||||
|
||||
; Backups view
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted."
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted."
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Records"
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records"
|
||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
||||
|
||||
; Backup detail view
|
||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Detail"
|
||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||
@@ -73,7 +76,7 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
||||
|
||||
; Profiles view
|
||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles"
|
||||
COM_MOKOJOOMBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles"
|
||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||
@@ -205,6 +208,7 @@ COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)"
|
||||
|
||||
; Status labels
|
||||
COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete"
|
||||
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
|
||||
COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running"
|
||||
COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed"
|
||||
COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
|
||||
@@ -247,9 +251,9 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails.
|
||||
; Retention
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 to use the global default from component options."
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)."
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 to use the global default from component options."
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)."
|
||||
|
||||
COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="<strong>Push Notifications (ntfy)</strong> — Send instant push notifications to your phone or desktop via <a href='https://ntfy.sh' target='_blank'>ntfy.sh</a> or a self-hosted ntfy server."
|
||||
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
|
||||
@@ -448,6 +452,8 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE="Compare Backups"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side."
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting."
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
|
||||
|
||||
; Snapshot ACL
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||
@@ -498,6 +504,12 @@ COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date.
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||
|
||||
; Cancel Stalled Backup
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED="Cancel Stalled"
|
||||
COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED="No backup records selected."
|
||||
COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING="None of the selected backups are in running status."
|
||||
COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled."
|
||||
|
||||
; Remote Destinations (multi-remote)
|
||||
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
@@ -18,7 +19,7 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to d
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
|
||||
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
|
||||
@@ -30,8 +31,8 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Records"
|
||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles"
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
@@ -116,3 +117,27 @@ COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selec
|
||||
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||
|
||||
; Cancel Stalled Backup
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED="Cancel Stalled"
|
||||
COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED="No backup records selected."
|
||||
COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING="None of the selected backups are in running status."
|
||||
COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled."
|
||||
|
||||
; Backup status
|
||||
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
|
||||
|
||||
; Delete feedback
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted."
|
||||
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted."
|
||||
|
||||
; ACL - Cancel
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
|
||||
|
||||
; Retention (per-profile)
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)."
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)."
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
|
||||
@@ -6,16 +6,25 @@
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<!--
|
||||
IMPORTANT: a component's <name> drives the Joomla-derived extension
|
||||
element (com_ + sanitized name), so it MUST stay element-safe:
|
||||
"MokoSuiteBackup" -> com_mokosuitebackup. Do NOT apply the "Type - Name"
|
||||
display convention here — "Component - MokoSuiteBackup" derives
|
||||
com_component-mokosuitebackup, which registers a duplicate component and
|
||||
orphans the real one. Packages are exempt because they set the element
|
||||
via <packagename>; plugins/modules are exempt because <name> is only a
|
||||
display label there.
|
||||
-->
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.43.16</version>
|
||||
<version>01.43.16</version>
|
||||
<version>02.56.13</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>COM_MOKOJOOMBACKUP_DESCRIPTION</description>
|
||||
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
||||
|
||||
<namespace path="src">Joomla\Component\MokoSuiteBackup</namespace>
|
||||
|
||||
@@ -38,20 +47,20 @@
|
||||
</update>
|
||||
|
||||
<administration>
|
||||
<menu img="class:archive">COM_MOKOJOOMBACKUP</menu>
|
||||
<menu img="class:archive">Backup</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokosuitebackup&view=dashboard"
|
||||
img="class:home"
|
||||
alt="Dashboard">COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD</menu>
|
||||
alt="Dashboard">Dashboard</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=backups"
|
||||
img="class:database"
|
||||
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
|
||||
alt="Backups">Backup Records</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=snapshots"
|
||||
img="class:camera"
|
||||
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
|
||||
alt="Snapshots">Content Snapshots</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=profiles"
|
||||
img="class:cog"
|
||||
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
|
||||
alt="Profiles">Backup Profiles</menu>
|
||||
</submenu>
|
||||
<files folder=".">
|
||||
<filename>access.xml</filename>
|
||||
|
||||
@@ -11,35 +11,10 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
||||
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
||||
`remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3',
|
||||
`ftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21,
|
||||
`ftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_key_data` MEDIUMTEXT,
|
||||
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)',
|
||||
`s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1',
|
||||
`s3_access_key` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_bucket` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||
`restore_script_name` VARCHAR(100) NOT NULL DEFAULT 'restore.php' COMMENT 'Custom restore script filename',
|
||||
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||
@@ -48,13 +23,12 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default',
|
||||
`retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default',
|
||||
`retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT 'Delete backups older than N days; 0 = unlimited',
|
||||
`retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT 'Keep newest N backups; 0 = unlimited',
|
||||
`ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name',
|
||||
`ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL',
|
||||
`ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)',
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -65,7 +39,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, fail',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, warning, fail',
|
||||
`origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled',
|
||||
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
|
||||
`archivename` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
@@ -113,14 +87,13 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
|
||||
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_enabled` (`enabled`)
|
||||
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||
@@ -128,12 +101,12 @@ INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
`archive_format`, `compression_level`, `split_size`, `backup_dir`,
|
||||
`exclude_dirs`, `exclude_files`, `exclude_tables`,
|
||||
`published`, `ordering`, `created`, `modified`
|
||||
`published`, `created`, `modified`
|
||||
) VALUES (
|
||||
1, 'Default Backup Profile', 'Full site backup with default settings', 'full',
|
||||
'zip', 5, 0, '[DEFAULT_DIR]',
|
||||
'administrator/components/com_mokosuitebackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
|
||||
'.gitignore\n.htaccess.bak',
|
||||
'#__session',
|
||||
1, 1, NOW(), NOW()
|
||||
1, NOW(), NOW()
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_snapshots`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.19 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.20 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.21 — no schema changes */
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 01.43.22 — Add restore_script_name to profiles, align remotes schema
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `restore_script_name` VARCHAR(100) NOT NULL DEFAULT 'restore.php' COMMENT 'Custom restore script filename'
|
||||
AFTER `include_mokorestore`;
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.23 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.24 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.25 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.26 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.29 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.30 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.31 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.32 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` DROP COLUMN `ordering`;
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.34 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.35 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.36 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.37 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.43.38 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.44.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.44.01 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.45.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.16 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.17 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.18 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.20 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.21 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.22 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.23 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.24 — no schema changes */
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Remove legacy single-remote storage columns (superseded by #__mokosuitebackup_remotes).
|
||||
-- Plain DROP COLUMN (no IF EXISTS): all columns are created by install.mysql.sql and
|
||||
-- earlier updates, so they always exist here. `DROP COLUMN IF EXISTS` is a MariaDB-only
|
||||
-- extension and errors on Oracle MySQL 8.x, which Joomla also supports.
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
DROP COLUMN `remote_storage`,
|
||||
DROP COLUMN `ftp_host`,
|
||||
DROP COLUMN `ftp_port`,
|
||||
DROP COLUMN `ftp_username`,
|
||||
DROP COLUMN `ftp_password`,
|
||||
DROP COLUMN `ftp_path`,
|
||||
DROP COLUMN `ftp_passive`,
|
||||
DROP COLUMN `ftp_ssl`,
|
||||
DROP COLUMN `sftp_host`,
|
||||
DROP COLUMN `sftp_port`,
|
||||
DROP COLUMN `sftp_username`,
|
||||
DROP COLUMN `sftp_auth_type`,
|
||||
DROP COLUMN `sftp_password`,
|
||||
DROP COLUMN `sftp_key_data`,
|
||||
DROP COLUMN `sftp_passphrase`,
|
||||
DROP COLUMN `sftp_path`,
|
||||
DROP COLUMN `gdrive_client_id`,
|
||||
DROP COLUMN `gdrive_client_secret`,
|
||||
DROP COLUMN `gdrive_refresh_token`,
|
||||
DROP COLUMN `gdrive_folder_id`,
|
||||
DROP COLUMN `s3_endpoint`,
|
||||
DROP COLUMN `s3_region`,
|
||||
DROP COLUMN `s3_access_key`,
|
||||
DROP COLUMN `s3_secret_key`,
|
||||
DROP COLUMN `s3_bucket`,
|
||||
DROP COLUMN `s3_path`;
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.52.27 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.53.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.54.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.55.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.55.03 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.00 — no schema changes */
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Purge legacy single-remote storage columns from installs where they are still present.
|
||||
--
|
||||
-- Background: 02.52.25.sql originally used `DROP COLUMN IF EXISTS`, which is a
|
||||
-- MariaDB-only extension and errors on Oracle MySQL 8.x. On MySQL 8 installs the
|
||||
-- migration failed but Joomla still recorded the schema as applied, leaving all 26
|
||||
-- legacy remote_storage/ftp_*/sftp_*/gdrive_*/s3_* columns stranded on the profiles
|
||||
-- table. This migration removes them portably.
|
||||
--
|
||||
-- It must be safe on BOTH engines AND on installs where the columns are already gone
|
||||
-- (MariaDB, or anyone who ran the corrected 02.52.25). Plain `DROP COLUMN` errors when
|
||||
-- a column is absent, and `DROP COLUMN IF EXISTS` errors on MySQL 8 — so neither works
|
||||
-- unconditionally. We gate the drop on INFORMATION_SCHEMA and build the ALTER via a
|
||||
-- prepared statement, which runs on MySQL 8 and MariaDB alike. All 26 columns were
|
||||
-- created and dropped together, so the presence of `remote_storage` gates the whole set.
|
||||
-- When the columns are absent this is a no-op (`DO 0`).
|
||||
|
||||
SET @moko_has_legacy_remote := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = '#__mokosuitebackup_profiles'
|
||||
AND COLUMN_NAME = 'remote_storage'
|
||||
);
|
||||
|
||||
SET @moko_drop_legacy_remote := IF(@moko_has_legacy_remote > 0,
|
||||
'ALTER TABLE `#__mokosuitebackup_profiles` DROP COLUMN `remote_storage`, DROP COLUMN `ftp_host`, DROP COLUMN `ftp_port`, DROP COLUMN `ftp_username`, DROP COLUMN `ftp_password`, DROP COLUMN `ftp_path`, DROP COLUMN `ftp_passive`, DROP COLUMN `ftp_ssl`, DROP COLUMN `sftp_host`, DROP COLUMN `sftp_port`, DROP COLUMN `sftp_username`, DROP COLUMN `sftp_auth_type`, DROP COLUMN `sftp_password`, DROP COLUMN `sftp_key_data`, DROP COLUMN `sftp_passphrase`, DROP COLUMN `sftp_path`, DROP COLUMN `gdrive_client_id`, DROP COLUMN `gdrive_client_secret`, DROP COLUMN `gdrive_refresh_token`, DROP COLUMN `gdrive_folder_id`, DROP COLUMN `s3_endpoint`, DROP COLUMN `s3_region`, DROP COLUMN `s3_access_key`, DROP COLUMN `s3_secret_key`, DROP COLUMN `s3_bucket`, DROP COLUMN `s3_path`',
|
||||
'DO 0'
|
||||
);
|
||||
|
||||
PREPARE moko_stmt FROM @moko_drop_legacy_remote;
|
||||
EXECUTE moko_stmt;
|
||||
DEALLOCATE PREPARE moko_stmt;
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.05 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.07 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.08 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.11 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 02.56.13 — no schema changes */
|
||||
@@ -84,6 +84,67 @@ class AjaxController extends BaseController
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a backup record stuck in "running" status.
|
||||
* POST: task=ajax.cancelBackup&id=123
|
||||
*/
|
||||
public function cancelBackup(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'status', 'absolute_path']))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'running') {
|
||||
$this->sendJson(['error' => true, 'message' => 'Backup is not in running status']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitebackup_records'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
|
||||
@unlink($record->absolute_path);
|
||||
}
|
||||
|
||||
$this->sendJson(['error' => false, 'message' => 'Backup cancelled']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse server directories for the folder picker field.
|
||||
* POST: task=ajax.browseDir&path=/some/path
|
||||
@@ -451,7 +512,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete' || !$record->filesexist) {
|
||||
if (!\in_array($record->status, ['complete', 'warning'], true) || !$record->filesexist) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
||||
|
||||
return;
|
||||
@@ -747,7 +808,7 @@ class AjaxController extends BaseController
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
|
||||
$db->setQuery($query);
|
||||
$count = (int) $db->loadResult();
|
||||
} catch (\Exception $e) {
|
||||
@@ -924,11 +985,11 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode JSON config and mask secrets
|
||||
// Decode JSON params and mask secrets
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$config = json_decode($row->config, true) ?: [];
|
||||
$config = json_decode($row->params, true) ?: [];
|
||||
|
||||
// Mask sensitive fields so they never leave the server in list views
|
||||
$masked = $this->maskSecrets($config, $row->type);
|
||||
@@ -939,8 +1000,7 @@ class AjaxController extends BaseController
|
||||
'title' => $row->title,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'keep_local' => (int) $row->keep_local,
|
||||
'config' => $masked,
|
||||
'params' => $masked,
|
||||
'ordering' => (int) $row->ordering,
|
||||
];
|
||||
}
|
||||
@@ -971,7 +1031,6 @@ class AjaxController extends BaseController
|
||||
$title = trim($this->input->getString('remote_title', ''));
|
||||
$type = $this->input->getCmd('remote_type', 'sftp');
|
||||
$enabled = $this->input->getInt('remote_enabled', 1);
|
||||
$keepLocal = $this->input->getInt('remote_keep_local', 1);
|
||||
$configRaw = $this->input->getString('remote_config', '{}');
|
||||
|
||||
if (!$profileId) {
|
||||
@@ -1019,9 +1078,7 @@ class AjaxController extends BaseController
|
||||
$table->title = $title;
|
||||
$table->type = $type;
|
||||
$table->enabled = $enabled ? 1 : 0;
|
||||
$table->keep_local = $keepLocal ? 1 : 0;
|
||||
$table->config = json_encode($config);
|
||||
|
||||
$table->params = json_encode($config);
|
||||
if (!$table->check() || !$table->store()) {
|
||||
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
||||
|
||||
@@ -1144,6 +1201,7 @@ class AjaxController extends BaseController
|
||||
private function maskSecrets(array $config, string $type): array
|
||||
{
|
||||
$secrets = [
|
||||
'ftp' => ['password'],
|
||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||
's3' => ['secret_key'],
|
||||
'google_drive' => ['client_secret', 'refresh_token'],
|
||||
@@ -1166,6 +1224,7 @@ class AjaxController extends BaseController
|
||||
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
||||
{
|
||||
$secrets = [
|
||||
'ftp' => ['password'],
|
||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||
's3' => ['secret_key'],
|
||||
'google_drive' => ['client_secret', 'refresh_token'],
|
||||
@@ -1190,7 +1249,7 @@ class AjaxController extends BaseController
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('config'))
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
@@ -1208,184 +1267,6 @@ class AjaxController extends BaseController
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse directories on a remote SFTP server for the path picker.
|
||||
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
||||
*/
|
||||
public function browseSftpDir(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if (!$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* Load the profile to get SFTP credentials */
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$profile) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = $profile->sftp_host ?? '';
|
||||
$port = (int) ($profile->sftp_port ?? 22);
|
||||
$username = $profile->sftp_username ?? '';
|
||||
$keyData = $profile->sftp_key_data ?? '';
|
||||
$password = $profile->sftp_password ?? '';
|
||||
|
||||
if (empty($host) || empty($username)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($keyData) && empty($password)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requestPath = $this->input->getString('path', '/');
|
||||
|
||||
/* Sanitize: must start with / and not contain shell meta-characters */
|
||||
$requestPath = '/' . ltrim($requestPath, '/');
|
||||
|
||||
if (preg_match('/[;&|`$<>]/', $requestPath)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keyFile = null;
|
||||
|
||||
try {
|
||||
/* Write temp key if using key auth (same pattern as SftpUploader) */
|
||||
if (!empty($keyData)) {
|
||||
$keyContent = base64_decode($keyData, true);
|
||||
|
||||
if ($keyContent === false) {
|
||||
$keyContent = $keyData;
|
||||
}
|
||||
|
||||
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
|
||||
|
||||
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||
}
|
||||
|
||||
chmod($keyFile, 0600);
|
||||
}
|
||||
|
||||
/* Build SSH command to list directories */
|
||||
$escapedPath = escapeshellarg($requestPath);
|
||||
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
|
||||
|
||||
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
||||
|
||||
if ($port !== 22) {
|
||||
$parts[] = '-p';
|
||||
$parts[] = (string) $port;
|
||||
}
|
||||
|
||||
if ($keyFile !== null) {
|
||||
$parts[] = '-i';
|
||||
$parts[] = escapeshellarg($keyFile);
|
||||
}
|
||||
|
||||
$parts[] = escapeshellarg($username . '@' . $host);
|
||||
$parts[] = escapeshellarg($remoteCmd);
|
||||
|
||||
$cmd = implode(' ', $parts);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
/* exitCode 1 from grep means no matches (empty dir), which is OK */
|
||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
|
||||
}
|
||||
|
||||
/* Parse output: each line is a directory name ending with / */
|
||||
$dirs = [];
|
||||
|
||||
foreach ($output as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || $line === './' || $line === '../') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dirName = rtrim($line, '/');
|
||||
|
||||
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
|
||||
|
||||
$dirs[] = [
|
||||
'name' => $dirName,
|
||||
'path' => $fullPath,
|
||||
];
|
||||
}
|
||||
|
||||
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
/* Parent path */
|
||||
$parent = null;
|
||||
|
||||
if ($requestPath !== '/') {
|
||||
$parent = \dirname($requestPath);
|
||||
|
||||
if ($parent === '') {
|
||||
$parent = '/';
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'current' => $requestPath,
|
||||
'parent' => $parent,
|
||||
'dirs' => $dirs,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
|
||||
} finally {
|
||||
if ($keyFile !== null && is_file($keyFile)) {
|
||||
unlink($keyFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
@@ -199,7 +199,7 @@ class BackupsController extends AdminController
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
|
||||
$db->setQuery($query);
|
||||
$ids = $db->loadColumn();
|
||||
|
||||
@@ -235,6 +235,76 @@ class BackupsController extends AdminController
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel selected backup records that are stuck in "running" status.
|
||||
*
|
||||
* Sets their status to "fail", cleans up partial archive files,
|
||||
* and destroys any associated stepped session.
|
||||
*/
|
||||
public function cancelStalled(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cid = $this->input->get('cid', [], 'array');
|
||||
|
||||
if (empty($cid)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->app->getContainer()->get('DatabaseDriver');
|
||||
$cancelled = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($cid as $id) {
|
||||
$id = (int) $id;
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'status', 'absolute_path']))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record || $record->status !== 'running') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitebackup_records'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
|
||||
@unlink($record->absolute_path);
|
||||
}
|
||||
|
||||
$cancelled++;
|
||||
}
|
||||
|
||||
if ($cancelled > 0) {
|
||||
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_CANCEL_SUCCESS', $cancelled));
|
||||
} elseif ($skipped > 0) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING'), 'warning');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op target for the purge toolbar button.
|
||||
*
|
||||
|
||||
@@ -228,28 +228,12 @@ class AkeebaImporter
|
||||
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
|
||||
'exclude_files' => implode("\n", $filters['exclude_files']),
|
||||
'exclude_tables' => implode("\n", $filters['exclude_tables']),
|
||||
'remote_storage' => $this->mapRemoteStorage($config),
|
||||
'ftp_host' => $config['engine.postproc.ftp.host'] ?? '',
|
||||
'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21),
|
||||
'ftp_username' => $config['engine.postproc.ftp.user'] ?? '',
|
||||
'ftp_password' => $config['engine.postproc.ftp.pass'] ?? '',
|
||||
'ftp_path' => $config['engine.postproc.ftp.initial_directory'] ?? '/backups',
|
||||
'ftp_passive' => (int) ($config['engine.postproc.ftp.passive_mode'] ?? 1),
|
||||
'ftp_ssl' => (int) ($config['engine.postproc.ftp.ftps'] ?? 0),
|
||||
'gdrive_client_id' => $config['engine.postproc.googledrive.client_id'] ?? '',
|
||||
'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '',
|
||||
'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '',
|
||||
'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '',
|
||||
's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '',
|
||||
's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1',
|
||||
's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''),
|
||||
's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''),
|
||||
's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '',
|
||||
's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups',
|
||||
// Remote storage is no longer stored on the profile — it lives in
|
||||
// #__mokosuitebackup_remotes. Akeeba remote settings are not imported;
|
||||
// re-add remote destinations on the profile's Remote tab after import.
|
||||
'remote_keep_local' => 1,
|
||||
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
|
||||
'published' => 1,
|
||||
'ordering' => (int) $akProfile->id,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
@@ -321,48 +321,6 @@ class BackupEngine
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
/* Backward-compat: fall back to legacy single-remote column */
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
try {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
|
||||
if ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$restoreBasename = basename($restoreScriptPath);
|
||||
$this->log('Uploading standalone ' . $restoreBasename . '...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename);
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone ' . $restoreBasename . ' uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write log file alongside the archive
|
||||
@@ -375,7 +333,7 @@ class BackupEngine
|
||||
// Final record update (includes fields needed by NotificationSender)
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
'status' => 'complete',
|
||||
'status' => $uploadFailed ? 'warning' : 'complete',
|
||||
'description' => $description,
|
||||
'backup_type' => $profile->backup_type,
|
||||
'archivename' => $archiveName,
|
||||
@@ -403,6 +361,17 @@ class BackupEngine
|
||||
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
|
||||
}
|
||||
|
||||
// Enforce per-profile retention (age and/or copy count).
|
||||
try {
|
||||
$pruned = RetentionManager::prune($db, $profile);
|
||||
|
||||
if ($pruned > 0) {
|
||||
$this->log('Retention: pruned ' . $pruned . ' old backup(s)');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: retention pass failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
||||
|
||||
@@ -519,23 +488,7 @@ class BackupEngine
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate remote uploader based on the storage type.
|
||||
* Legacy method — used by backward-compat fallback when remotes table
|
||||
* does not exist.
|
||||
*/
|
||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||
{
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||
* Create a remote uploader from JSON params.
|
||||
*
|
||||
* Builds a fake profile-like object from the params array so the existing
|
||||
* uploader constructors work without modification.
|
||||
@@ -547,7 +500,16 @@ class BackupEngine
|
||||
*/
|
||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||
{
|
||||
$fake = (object) $params;
|
||||
$prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
|
||||
$prefix = $prefixMap[$type] ?? '';
|
||||
|
||||
$prefixed = [];
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$prefixed[$prefix . $key] = $value;
|
||||
}
|
||||
|
||||
$fake = (object) $prefixed;
|
||||
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($fake),
|
||||
@@ -560,31 +522,18 @@ class BackupEngine
|
||||
|
||||
/**
|
||||
* Load enabled remote destinations for a profile from the remotes table.
|
||||
*
|
||||
* Returns an empty array when the table does not exist (pre-migration)
|
||||
* so the caller can fall back to the legacy single-remote column.
|
||||
*
|
||||
* @param object $db Database driver
|
||||
* @param int $profileId Profile ID
|
||||
*
|
||||
* @return object[] Array of remote destination rows
|
||||
*/
|
||||
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||
{
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||
return [];
|
||||
}
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -597,7 +546,7 @@ class BackupEngine
|
||||
->select($db->quoteName('manifest'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
|
||||
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
|
||||
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
|
||||
@@ -326,7 +326,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
|
||||
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\n");
|
||||
fwrite($fp, $createSql . ";\n\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ use Joomla\CMS\Factory;
|
||||
|
||||
class DatabaseImporter
|
||||
{
|
||||
/**
|
||||
* Non-fatal per-statement errors collected during the last import().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* Import a SQL dump file into the database.
|
||||
*
|
||||
@@ -31,6 +38,8 @@ class DatabaseImporter
|
||||
*/
|
||||
public function import(string $sqlFile): int
|
||||
{
|
||||
$this->errors = [];
|
||||
|
||||
if (!is_file($sqlFile) || !is_readable($sqlFile)) {
|
||||
throw new \RuntimeException('SQL file not readable: ' . $sqlFile);
|
||||
}
|
||||
@@ -97,8 +106,10 @@ class DatabaseImporter
|
||||
} catch (\Exception $e) {
|
||||
// Log but don't abort — some statements may fail on
|
||||
// different MySQL versions (e.g. charset differences)
|
||||
// but the overall restore should continue.
|
||||
// but the overall restore should continue. Errors are
|
||||
// collected so the caller can surface a warning status.
|
||||
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
|
||||
$this->errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +126,7 @@ class DatabaseImporter
|
||||
$statementsExecuted++;
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
|
||||
$this->errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -123,4 +135,24 @@ class DatabaseImporter
|
||||
|
||||
return $statementsExecuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-fatal errors from the last import(), if any. A non-empty result
|
||||
* means the restore completed with problems and should be treated as a
|
||||
* warning rather than a clean success.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the last import() had any non-fatal statement errors.
|
||||
*/
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return $this->errors !== [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,9 @@ define('MOKOJOOMBACKUP_RESTORE', 1);
|
||||
define('RESTORE_DIR', __DIR__);
|
||||
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
|
||||
|
||||
error_log('MokoRestore: Script loaded — RESTORE_DIR=' . RESTORE_DIR);
|
||||
error_log('MokoRestore: PHP ' . PHP_VERSION . ', SAPI=' . php_sapi_name() . ', memory_limit=' . ini_get('memory_limit'));
|
||||
|
||||
session_start();
|
||||
|
||||
if (empty($_SESSION['restore_token'])) {
|
||||
@@ -358,25 +361,37 @@ $token = $_SESSION['restore_token'];
|
||||
// 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';
|
||||
$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 (or recreate) the security file whenever verification is still pending
|
||||
if (empty($_SESSION['security_verified']) && !is_file($securityFile)) {
|
||||
error_log('MokoRestore: Writing security file: ' . $securityFile);
|
||||
error_log('MokoRestore: Target directory: ' . RESTORE_DIR . ' (writable: ' . (is_writable(RESTORE_DIR) ? 'yes' : 'NO') . ')');
|
||||
|
||||
// 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
|
||||
|
||||
$written = @file_put_contents($securityFile, $securityContent);
|
||||
|
||||
if ($written === false) {
|
||||
$err = error_get_last();
|
||||
error_log('MokoRestore: FAILED to write security file — ' . ($err['message'] ?? 'unknown error'));
|
||||
error_log('MokoRestore: Directory permissions: ' . decoct(@fileperms(RESTORE_DIR) & 0777) . ', owner: ' . @fileowner(RESTORE_DIR) . ', PHP user: ' . (function_exists('posix_getuid') ? posix_getuid() : 'n/a'));
|
||||
error_log('MokoRestore: Security verification SKIPPED — user will not be challenged');
|
||||
$_SESSION['security_verified'] = true;
|
||||
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)');
|
||||
} else {
|
||||
error_log('MokoRestore: Security file created (' . $written . ' bytes)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,15 +402,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
|
||||
if ($inputCode === $securityCode) {
|
||||
$_SESSION['security_verified'] = true;
|
||||
error_log('MokoRestore: Security code VERIFIED');
|
||||
|
||||
// Delete the security file
|
||||
if (is_file($securityFile)) {
|
||||
@unlink($securityFile);
|
||||
error_log('MokoRestore: Security file deleted');
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Security verified']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']);
|
||||
error_log('MokoRestore: Security code REJECTED (input=' . $inputCode . ')');
|
||||
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: mokorestore-security.php']);
|
||||
}
|
||||
|
||||
exit;
|
||||
@@ -414,7 +431,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
}
|
||||
|
||||
if (!$securityVerified) {
|
||||
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']);
|
||||
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from mokorestore-security.php']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -424,9 +441,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
@ignore_user_abort(true);
|
||||
|
||||
try {
|
||||
error_log('MokoRestore: Action dispatched — ' . $_POST['action']);
|
||||
$result = handleAction($_POST['action'], $_POST);
|
||||
error_log('MokoRestore: Action ' . $_POST['action'] . ' completed — ' . ($result['success'] ? 'OK' : 'FAIL: ' . ($result['message'] ?? '')));
|
||||
echo json_encode($result);
|
||||
} catch (Throwable $e) {
|
||||
error_log('MokoRestore: Action ' . $_POST['action'] . ' EXCEPTION — ' . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -551,10 +571,14 @@ function actionPreflight(): array
|
||||
|
||||
function actionExtract(array $data): array
|
||||
{
|
||||
error_log('MokoRestore: Extract — target=' . BACKUP_FILE . ', exists=' . (file_exists(BACKUP_FILE) ? 'yes' : 'no'));
|
||||
|
||||
if (!file_exists(BACKUP_FILE)) {
|
||||
throw new RuntimeException('Backup file not found: site-backup.zip');
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Extract — archive size=' . number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB');
|
||||
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open(BACKUP_FILE) !== true) {
|
||||
@@ -591,6 +615,8 @@ function actionExtract(array $data): array
|
||||
$count = $zip->numFiles;
|
||||
$zip->close();
|
||||
|
||||
error_log('MokoRestore: Extract — ' . $count . ' files extracted to ' . RESTORE_DIR);
|
||||
|
||||
// Pre-fill from configuration.php.bak (sanitized backup) or
|
||||
// configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values.
|
||||
$existingConfig = [];
|
||||
@@ -719,6 +745,8 @@ function actionDatabase(array $data): array
|
||||
$user = $data['db_user'] ?? '';
|
||||
$pass = $data['db_pass'] ?? '';
|
||||
|
||||
error_log('MokoRestore: Database import — host=' . $host . ', db=' . $name . ', user=' . $user);
|
||||
|
||||
if (empty($name) || empty($user)) {
|
||||
throw new RuntimeException('Database name and user are required');
|
||||
}
|
||||
@@ -726,9 +754,12 @@ function actionDatabase(array $data): array
|
||||
$sqlFile = RESTORE_DIR . '/database.sql';
|
||||
|
||||
if (!is_file($sqlFile)) {
|
||||
error_log('MokoRestore: Database import — no database.sql found, skipping');
|
||||
return ['success' => true, 'message' => 'No database.sql found — skipped', 'statements' => 0, 'errors' => 0];
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Database import — SQL file size=' . number_format(filesize($sqlFile) / 1048576, 2) . ' MB');
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||||
$user,
|
||||
@@ -835,6 +866,14 @@ function actionDatabase(array $data): array
|
||||
$msg .= " ({$errors} warnings)";
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Database import — ' . $msg);
|
||||
|
||||
if (!empty($errorList)) {
|
||||
foreach ($errorList as $i => $err) {
|
||||
error_log('MokoRestore: DB error ' . ($i + 1) . ': ' . $err);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => ($statements > 0 || $errors === 0),
|
||||
'message' => $msg,
|
||||
@@ -847,6 +886,7 @@ function actionDatabase(array $data): array
|
||||
|
||||
function actionConfig(array $data): array
|
||||
{
|
||||
error_log('MokoRestore: Config rebuild started');
|
||||
$host = $data['db_host'] ?? 'localhost';
|
||||
$dbName = $data['db_name'] ?? '';
|
||||
$dbUser = $data['db_user'] ?? '';
|
||||
@@ -867,6 +907,7 @@ function actionConfig(array $data): array
|
||||
// debug, cache, SEF, editor, etc.). Fall back to existing config
|
||||
// for legacy/unsanitized backups, or build from scratch if neither exists.
|
||||
$basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null);
|
||||
error_log('MokoRestore: Config — base template: ' . ($basePath ?? 'none (building from scratch)'));
|
||||
|
||||
if ($basePath !== null) {
|
||||
$config = file_get_contents($basePath);
|
||||
@@ -919,9 +960,12 @@ function actionConfig(array $data): array
|
||||
}
|
||||
|
||||
if (file_put_contents($configPath, $config) === false) {
|
||||
error_log('MokoRestore: Config — FAILED to write ' . $configPath);
|
||||
return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions'];
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Config — written to ' . $configPath . ' (' . filesize($configPath) . ' bytes)');
|
||||
|
||||
// Remove .bak after successful rebuild
|
||||
if (is_file($bakPath)) {
|
||||
@unlink($bakPath);
|
||||
@@ -1175,6 +1219,8 @@ function actionResetAdmin(array $data): array
|
||||
$userId = (int) ($data['admin_id'] ?? 0);
|
||||
$password = $data['new_password'] ?? '';
|
||||
|
||||
error_log('MokoRestore: Admin password reset — user_id=' . $userId);
|
||||
|
||||
if ($userId < 1 || strlen($password) < 8) {
|
||||
throw new RuntimeException('Select an admin and enter a password (8+ characters)');
|
||||
}
|
||||
@@ -1188,6 +1234,7 @@ function actionResetAdmin(array $data): array
|
||||
throw new RuntimeException('User not found or password unchanged');
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Admin password reset — success');
|
||||
return ['success' => true, 'message' => 'Admin password updated successfully'];
|
||||
}
|
||||
|
||||
@@ -1197,6 +1244,7 @@ function actionPostRestore(array $data): array
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
||||
$results = [];
|
||||
error_log('MokoRestore: Post-restore — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
@@ -1319,6 +1367,7 @@ function actionProvision(array $data): array
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
||||
$results = [];
|
||||
error_log('MokoRestore: Provisioning — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
@@ -1395,16 +1444,24 @@ function actionProvision(array $data): array
|
||||
|
||||
function actionCleanup(): array
|
||||
{
|
||||
error_log('MokoRestore: Cleanup started');
|
||||
$removed = [];
|
||||
|
||||
foreach (['database.sql', 'site-backup.zip'] as $file) {
|
||||
foreach (['database.sql', 'site-backup.zip', 'mokorestore-security.php'] as $file) {
|
||||
$path = RESTORE_DIR . '/' . $file;
|
||||
|
||||
if (is_file($path) && @unlink($path)) {
|
||||
$removed[] = $file;
|
||||
if (is_file($path)) {
|
||||
if (@unlink($path)) {
|
||||
$removed[] = $file;
|
||||
error_log('MokoRestore: Cleanup — removed ' . $file);
|
||||
} else {
|
||||
error_log('MokoRestore: Cleanup — FAILED to remove ' . $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error_log('MokoRestore: Cleanup complete — removed ' . count($removed) . ' file(s)');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed))
|
||||
@@ -1570,14 +1627,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
<!-- 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>
|
||||
<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>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>
|
||||
|
||||
@@ -77,7 +77,7 @@ class PreflightCheck
|
||||
$this->checkDiskSpace($profile, $db);
|
||||
$this->checkRunningBackup($profile, $db);
|
||||
$this->checkExcludedTables($profile, $db);
|
||||
$this->checkRemoteCredentials($profile);
|
||||
$this->checkRemoteCredentials($profile, $db);
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
@@ -102,12 +102,8 @@ class PreflightCheck
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) {
|
||||
$this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +161,7 @@ class PreflightCheck
|
||||
->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('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
|
||||
->where($db->quoteName('total_size') . ' > 0')
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
@@ -194,22 +190,58 @@ class PreflightCheck
|
||||
}
|
||||
}
|
||||
|
||||
private const STALE_TIMEOUT_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* Check if another backup is already running for this profile.
|
||||
*
|
||||
* Backups running longer than STALE_TIMEOUT_MINUTES are automatically
|
||||
* marked as failed so they don't permanently block future runs.
|
||||
*/
|
||||
private function checkRunningBackup(object $profile, object $db): void
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->select($db->quoteName(['id', 'backupstart', 'absolute_path']))
|
||||
->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();
|
||||
$rows = $db->loadObjectList();
|
||||
|
||||
if ($running > 0) {
|
||||
if (empty($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = time() - (self::STALE_TIMEOUT_MINUTES * 60);
|
||||
$stillAlive = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$started = strtotime($row->backupstart);
|
||||
|
||||
if ($started !== false && $started < $cutoff) {
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitebackup_records'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $row->id);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
if (!empty($row->absolute_path) && is_file($row->absolute_path)) {
|
||||
@unlink($row->absolute_path);
|
||||
}
|
||||
|
||||
$this->warnings[] = 'Auto-cancelled stalled backup #' . $row->id
|
||||
. ' (started ' . $row->backupstart . ', exceeded '
|
||||
. self::STALE_TIMEOUT_MINUTES . ' min timeout)';
|
||||
} else {
|
||||
$stillAlive++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stillAlive > 0) {
|
||||
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
|
||||
. ' — wait for it to finish or delete the stale record';
|
||||
. ' — wait for it to finish or use Cancel Stalled from the Backup Records toolbar';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,65 +276,76 @@ class PreflightCheck
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that remote storage credentials are minimally configured.
|
||||
* Check that remote destination credentials are minimally configured.
|
||||
* Does not test the actual connection (too slow for preflight).
|
||||
*/
|
||||
private function checkRemoteCredentials(object $profile): void
|
||||
private function checkRemoteCredentials(object $profile, object $db): void
|
||||
{
|
||||
$remote = $profile->remote_storage ?? 'none';
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('enabled') . ' = 1');
|
||||
$db->setQuery($query);
|
||||
$remotes = $db->loadObjectList();
|
||||
|
||||
if ($remote === 'none') {
|
||||
if (empty($remotes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($remote) {
|
||||
case 'ftp':
|
||||
if (empty($profile->ftp_host)) {
|
||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
||||
}
|
||||
foreach ($remotes as $remote) {
|
||||
$params = json_decode($remote->params, true) ?: [];
|
||||
$label = $remote->title ?: ('Remote #' . $remote->id);
|
||||
|
||||
if (empty($profile->ftp_username)) {
|
||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
||||
}
|
||||
switch ($remote->type) {
|
||||
case 'ftp':
|
||||
if (empty($params['host'])) {
|
||||
$this->warnings[] = $label . ': FTP host is not configured — upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
if (empty($params['username'])) {
|
||||
$this->warnings[] = $label . ': FTP username is not configured — upload will fail';
|
||||
}
|
||||
|
||||
case 's3':
|
||||
if (empty($profile->s3_bucket)) {
|
||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
||||
}
|
||||
break;
|
||||
|
||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
||||
}
|
||||
case 's3':
|
||||
if (empty($params['bucket'])) {
|
||||
$this->warnings[] = $label . ': S3 bucket is not configured — upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
if (empty($params['access_key']) || empty($params['secret_key'])) {
|
||||
$this->warnings[] = $label . ': S3 credentials are not configured — upload will fail';
|
||||
}
|
||||
|
||||
case 'sftp':
|
||||
if (empty($profile->sftp_host)) {
|
||||
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
||||
}
|
||||
break;
|
||||
|
||||
if (empty($profile->sftp_username)) {
|
||||
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
||||
}
|
||||
case 'sftp':
|
||||
if (empty($params['host'])) {
|
||||
$this->warnings[] = $label . ': SFTP host is not configured — upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
||||
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
||||
}
|
||||
if (empty($params['username'])) {
|
||||
$this->warnings[] = $label . ': SFTP username is not configured — upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
if (empty($params['key_data']) && empty($params['password'])) {
|
||||
$this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
break;
|
||||
|
||||
if (empty($profile->gdrive_refresh_token)) {
|
||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
||||
}
|
||||
case 'google_drive':
|
||||
if (empty($params['client_id']) || empty($params['client_secret'])) {
|
||||
$this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
if (empty($params['refresh_token'])) {
|
||||
$this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class RestoreEngine
|
||||
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
if ($record->status !== 'complete' && $record->status !== 'warning') {
|
||||
return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
/**
|
||||
* Enforces per-profile backup retention.
|
||||
*
|
||||
* A profile may cap retained backups by age (retention_days) and/or by
|
||||
* number of copies (retention_count). A backup is pruned when EITHER rule
|
||||
* matches: it is older than retention_days OR it falls outside the newest
|
||||
* retention_count copies. Deleting a record also removes its archive and
|
||||
* log file, mirroring the Backup table's delete().
|
||||
*/
|
||||
final class RetentionManager
|
||||
{
|
||||
/**
|
||||
* Prune old backups for a profile according to its retention settings.
|
||||
*
|
||||
* Called after a backup completes. Only 'complete' and 'warning' records
|
||||
* are considered — pending/running/failed records are never pruned here.
|
||||
*
|
||||
* @param object $db Database driver
|
||||
* @param object $profile Profile row (needs id, retention_days, retention_count)
|
||||
*
|
||||
* @return int Number of backup records deleted
|
||||
*/
|
||||
public static function prune(object $db, object $profile): int
|
||||
{
|
||||
$days = (int) ($profile->retention_days ?? 0);
|
||||
$count = (int) ($profile->retention_count ?? 0);
|
||||
|
||||
// No retention configured — nothing to do.
|
||||
if ($days <= 0 && $count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Newest first, so the index is the copy's position from the top.
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'absolute_path', 'backupstart']))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query);
|
||||
$records = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($records)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($records as $index => $record) {
|
||||
$tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs;
|
||||
$overCount = $count > 0 && $index >= $count;
|
||||
|
||||
// Delete-if-either: prune when age OR count rule is exceeded.
|
||||
if (!$tooOld && !$overCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::deleteRecord($db, $record)) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single backup record and its on-disk archive + log file.
|
||||
*
|
||||
* The DB row is removed first; the files are only unlinked if that
|
||||
* succeeds, so a failed delete never orphans the record from its files.
|
||||
*/
|
||||
private static function deleteRecord(object $db, object $record): bool
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $record->id);
|
||||
$db->setQuery($query);
|
||||
|
||||
try {
|
||||
$db->execute();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$archivePath = (string) ($record->absolute_path ?? '');
|
||||
|
||||
if ($archivePath !== '' && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
|
||||
$logPath = BackupDirectory::logPathFromArchive($archivePath);
|
||||
|
||||
if (is_file($logPath)) {
|
||||
@unlink($logPath);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ class SftpUploader implements RemoteUploaderInterface
|
||||
*/
|
||||
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
|
||||
{
|
||||
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||
$parts = ['scp', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes'];
|
||||
|
||||
if ($this->port !== 22) {
|
||||
$parts[] = '-P';
|
||||
@@ -235,7 +235,7 @@ class SftpUploader implements RemoteUploaderInterface
|
||||
*/
|
||||
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
|
||||
{
|
||||
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||
$parts = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes'];
|
||||
|
||||
if ($this->port !== 22) {
|
||||
$parts[] = '-p';
|
||||
|
||||
@@ -69,7 +69,6 @@ class SteppedBackupEngine
|
||||
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
||||
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$session->remoteStorage = $profile->remote_storage ?? 'none';
|
||||
$session->includeMokoRestore = $profile->include_mokorestore ?? '0';
|
||||
$session->restoreScriptName = $profile->restore_script_name ?? 'restore.php';
|
||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||
@@ -153,15 +152,8 @@ class SteppedBackupEngine
|
||||
|
||||
$totalSteps += 1; // finalize step
|
||||
|
||||
// Determine upload step count: one step per remote destination,
|
||||
// or one step for legacy single-remote, or zero if no remotes.
|
||||
$remoteCount = count($session->remoteDestinations);
|
||||
|
||||
if ($remoteCount > 0) {
|
||||
$totalSteps += $remoteCount;
|
||||
} elseif ($session->remoteStorage !== 'none') {
|
||||
$totalSteps += 1;
|
||||
}
|
||||
$totalSteps += $remoteCount;
|
||||
|
||||
$session->totalSteps = $totalSteps;
|
||||
$session->currentStep = 1;
|
||||
@@ -394,8 +386,14 @@ class SteppedBackupEngine
|
||||
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||
$restoreDir = dirname($session->archivePath);
|
||||
$session->restoreScriptPath = $restoreDir . '/' . $restoreScriptName;
|
||||
MokoRestore::generateStandalone($session->restoreScriptPath);
|
||||
$session->log('Standalone ' . $restoreScriptName . ' generated');
|
||||
|
||||
try {
|
||||
MokoRestore::generateStandalone($session->restoreScriptPath);
|
||||
$session->log('Standalone ' . $restoreScriptName . ' generated');
|
||||
} catch (\Throwable $e) {
|
||||
$session->log('MokoRestore error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$session->log('Stack trace: ' . $e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
|
||||
// Update record
|
||||
@@ -415,11 +413,7 @@ class SteppedBackupEngine
|
||||
|
||||
$session->currentStep++;
|
||||
|
||||
// Determine next phase: multi-remote, legacy single-remote, or complete
|
||||
$hasMultiRemote = !empty($session->remoteDestinations);
|
||||
$hasLegacyRemote = $session->remoteStorage !== 'none';
|
||||
|
||||
if ($hasMultiRemote || $hasLegacyRemote) {
|
||||
if (!empty($session->remoteDestinations)) {
|
||||
$session->phase = 'upload';
|
||||
} else {
|
||||
$session->phase = 'complete';
|
||||
@@ -434,11 +428,7 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload phase: send archive to remote storage.
|
||||
*
|
||||
* When multi-remote destinations are configured, each call uploads to
|
||||
* one destination (one step per remote). When only the legacy
|
||||
* single-remote column is set, uploads in a single step.
|
||||
* Upload phase: send archive to one remote destination per call.
|
||||
*/
|
||||
private function stepUpload(SteppedSession $session): void
|
||||
{
|
||||
@@ -446,133 +436,65 @@ class SteppedBackupEngine
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
if (!empty($session->remoteDestinations)) {
|
||||
// ── Multi-remote path ──────────────────────────────────
|
||||
$index = $session->remoteIndex;
|
||||
$index = $session->remoteIndex;
|
||||
|
||||
if ($index >= count($session->remoteDestinations)) {
|
||||
// All remotes processed — move to complete
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'All remote uploads finished';
|
||||
$this->completeRecord($session);
|
||||
if ($index >= count($session->remoteDestinations)) {
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'All remote uploads finished';
|
||||
$this->completeRecord($session);
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$remote = (object) $session->remoteDestinations[$index];
|
||||
$remote = (object) $session->remoteDestinations[$index];
|
||||
|
||||
try {
|
||||
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||
$type = $remote->type ?? 'unknown';
|
||||
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||
try {
|
||||
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||
$type = $remote->type ?? 'unknown';
|
||||
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||
|
||||
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||
$uploader = $this->createUploaderFromParams($type, $params);
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||
$uploader = $this->createUploaderFromParams($type, $params);
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log(' Upload complete: ' . $result['message']);
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log(' Upload complete: ' . $result['message']);
|
||||
|
||||
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$session->remoteIndex++;
|
||||
$session->currentStep++;
|
||||
|
||||
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||
|
||||
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
|
||||
$session->remoteIndex++;
|
||||
$session->currentStep++;
|
||||
|
||||
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||
|
||||
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||
// All remotes done — delete local if configured and no failures
|
||||
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
} else {
|
||||
// ── Legacy single-remote fallback ──────────────────────
|
||||
try {
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
|
||||
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||
$restoreBasename = basename($session->restoreScriptPath);
|
||||
$session->log('Uploading standalone ' . $restoreBasename . '...');
|
||||
$uploader->upload($session->restoreScriptPath, $restoreBasename);
|
||||
}
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
@@ -641,7 +563,7 @@ class SteppedBackupEngine
|
||||
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'status' => 'complete',
|
||||
'status' => $uploadFailed ? 'warning' : 'complete',
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'total_size' => $totalSize,
|
||||
'checksum' => $checksum,
|
||||
@@ -680,6 +602,13 @@ class SteppedBackupEngine
|
||||
if ($uploadFailed) {
|
||||
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
|
||||
}
|
||||
|
||||
// Enforce per-profile retention (age and/or copy count).
|
||||
$pruned = RetentionManager::prune($db, $profile);
|
||||
|
||||
if ($pruned > 0) {
|
||||
$session->log('Retention: pruned ' . $pruned . ' old backup(s)');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
||||
@@ -853,21 +782,15 @@ class SteppedBackupEngine
|
||||
*/
|
||||
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||
{
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
// Use loadAssocList so the data survives JSON serialization in SteppedSession
|
||||
return $db->loadAssocList() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||
return [];
|
||||
}
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -883,7 +806,16 @@ class SteppedBackupEngine
|
||||
*/
|
||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||
{
|
||||
$fake = (object) $params;
|
||||
$prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
|
||||
$prefix = $prefixMap[$type] ?? '';
|
||||
|
||||
$prefixed = [];
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$prefixed[$prefix . $key] = $value;
|
||||
}
|
||||
|
||||
$fake = (object) $prefixed;
|
||||
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($fake),
|
||||
|
||||
@@ -64,7 +64,7 @@ class SteppedRestoreEngine
|
||||
return ['error' => true, 'message' => 'Backup record not found: ' . $recordId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
if ($record->status !== 'complete' && $record->status !== 'warning') {
|
||||
return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ class SteppedSession
|
||||
public array $excludeDirs = [];
|
||||
public array $excludeFiles = [];
|
||||
public array $excludeTables = [];
|
||||
public string $remoteStorage = 'none';
|
||||
public string $includeMokoRestore = '0';
|
||||
public string $restoreScriptName = 'restore.php';
|
||||
public string $restoreScriptPath = '';
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* SFTP remote path field with Browse Remote button and modal directory browser.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class SftpPathField extends FormField
|
||||
{
|
||||
protected $type = 'SftpPath';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="input-group">
|
||||
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||
class="form-control" maxlength="512"
|
||||
placeholder="/backups" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
|
||||
title="Browse directories on the remote SFTP server">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse Remote
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{$id}_sftpModalLabel">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse Remote SFTP Directory
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="{$id}_sftpStatus" class="mb-2">
|
||||
<small class="text-muted">Click "Browse Remote" to connect...</small>
|
||||
</div>
|
||||
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
|
||||
/
|
||||
</div>
|
||||
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
|
||||
<br>SFTP credentials must be saved in the profile before browsing.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
|
||||
<span class="icon-checkmark" aria-hidden="true"></span>
|
||||
Select This Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var fieldId = '{$id}';
|
||||
var input = document.getElementById(fieldId);
|
||||
var browseBtn = document.getElementById(fieldId + '_browseBtn');
|
||||
var modalEl = document.getElementById(fieldId + '_sftpModal');
|
||||
var treeEl = document.getElementById(fieldId + '_sftpTree');
|
||||
var statusEl = document.getElementById(fieldId + '_sftpStatus');
|
||||
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
|
||||
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
|
||||
var currentPath = '/';
|
||||
|
||||
function getProfileId() {
|
||||
var el = document.getElementById('jform_id');
|
||||
return el ? parseInt(el.value, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the status message using safe DOM methods (no innerHTML).
|
||||
* @param {string} cssClass - CSS class for the small element
|
||||
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
|
||||
* @param {string} text - Plain text message
|
||||
*/
|
||||
function setStatus(cssClass, iconClass, text) {
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
var small = document.createElement('small');
|
||||
small.className = cssClass;
|
||||
if (iconClass) {
|
||||
var icon = document.createElement('span');
|
||||
icon.className = iconClass;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
small.appendChild(icon);
|
||||
small.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
small.appendChild(document.createTextNode(text));
|
||||
statusEl.appendChild(small);
|
||||
}
|
||||
|
||||
function loadSftpDir(path) {
|
||||
currentPath = path;
|
||||
currentEl.textContent = path;
|
||||
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
|
||||
|
||||
var profileId = getProfileId();
|
||||
if (!profileId) {
|
||||
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
|
||||
return;
|
||||
}
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseSftpDir');
|
||||
form.append('profile_id', profileId);
|
||||
form.append('path', path);
|
||||
|
||||
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokosuitebackup&format=json', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
setStatus('text-danger', 'icon-warning', data.message || 'Error');
|
||||
return;
|
||||
}
|
||||
var count = data.dirs ? data.dirs.length : 0;
|
||||
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
|
||||
currentPath = data.current || path;
|
||||
currentEl.textContent = currentPath;
|
||||
renderSftpTree(data);
|
||||
})
|
||||
.catch(function(err) {
|
||||
setStatus('text-danger', 'icon-warning', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSftpTree(data) {
|
||||
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||
var list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
|
||||
/* Parent / back button */
|
||||
if (data.parent !== null && data.parent !== undefined) {
|
||||
var up = document.createElement('a');
|
||||
up.href = '#';
|
||||
up.className = 'list-group-item list-group-item-action py-1';
|
||||
var upIcon = document.createElement('span');
|
||||
upIcon.className = 'icon-arrow-up-4';
|
||||
upIcon.setAttribute('aria-hidden', 'true');
|
||||
up.appendChild(upIcon);
|
||||
up.appendChild(document.createTextNode(' .. (parent directory)'));
|
||||
up.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadSftpDir(data.parent);
|
||||
});
|
||||
list.appendChild(up);
|
||||
}
|
||||
|
||||
/* Directory entries */
|
||||
var dirs = data.dirs || [];
|
||||
|
||||
dirs.forEach(function(dir) {
|
||||
var item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'list-group-item list-group-item-action py-1';
|
||||
var folderIcon = document.createElement('span');
|
||||
folderIcon.className = 'icon-folder';
|
||||
folderIcon.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(folderIcon);
|
||||
item.appendChild(document.createTextNode(' ' + dir.name));
|
||||
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadSftpDir(dir.path);
|
||||
});
|
||||
|
||||
/* Double-click to select and close */
|
||||
item.addEventListener('dblclick', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
hideModal();
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
if (dirs.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'list-group-item text-muted py-2';
|
||||
empty.textContent = '(no subdirectories)';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
treeEl.appendChild(list);
|
||||
}
|
||||
|
||||
/* Browse button click */
|
||||
browseBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var startPath = input.value.trim() || '/';
|
||||
showModal();
|
||||
loadSftpDir(startPath);
|
||||
});
|
||||
|
||||
/* Select button — use the current directory */
|
||||
selectBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = currentPath;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
hideModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ class SshKeyField extends FormField
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
|
||||
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
|
||||
$decoded = !empty($value) ? (base64_decode($value, true) ?: '') : '';
|
||||
$hasKey = !empty($value) && ($value === '__KEEP_EXISTING__'
|
||||
|| str_contains($value, 'PRIVATE KEY')
|
||||
|| str_contains($decoded, 'PRIVATE KEY'));
|
||||
|
||||
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class BackupStatusHelper
|
||||
])
|
||||
->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'])) . ')')
|
||||
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning', 'fail'])) . ')')
|
||||
->order($db->quoteName('r.backupstart') . ' DESC');
|
||||
|
||||
if ($profileId !== null) {
|
||||
@@ -148,7 +148,7 @@ class BackupStatusHelper
|
||||
$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'])) . ')')
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning', 'fail'])) . ')')
|
||||
->order($db->quoteName('backupstart') . ' DESC')
|
||||
->setLimit(50);
|
||||
|
||||
@@ -156,7 +156,7 @@ class BackupStatusHelper
|
||||
$streak = 0;
|
||||
|
||||
foreach ($statuses as $s) {
|
||||
if ($s === 'complete') {
|
||||
if ($s === 'complete' || $s === 'warning') {
|
||||
$streak++;
|
||||
} else {
|
||||
break;
|
||||
|
||||
@@ -30,7 +30,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
->select('r.*, p.title AS 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') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
|
||||
->order($db->quoteName('r.backupend') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
@@ -75,7 +75,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
->select('COUNT(*) AS total_count')
|
||||
->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
|
||||
$db->setQuery($query);
|
||||
$stats = $db->loadObject();
|
||||
|
||||
@@ -274,7 +274,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
->select('COALESCE(SUM(r.total_size), 0) AS total_size')
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
||||
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
|
||||
->group($db->quoteName('r.profile_id'))
|
||||
->order('total_size DESC');
|
||||
$db->setQuery($query);
|
||||
@@ -294,7 +294,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
->select($db->quoteName(['id', 'title', 'backup_type']))
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
->order($db->quoteName('id') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
|
||||
@@ -25,7 +25,6 @@ class ProfilesModel extends ListModel
|
||||
'title', 'a.title',
|
||||
'backup_type', 'a.backup_type',
|
||||
'published', 'a.published',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database');
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database');
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($this->item->status === 'complete'
|
||||
if (\in_array($this->item->status, ['complete', 'warning'], true)
|
||||
&& !empty($this->item->filesexist)
|
||||
&& $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')
|
||||
) {
|
||||
|
||||
@@ -99,7 +99,7 @@ class HtmlView extends BaseHtmlView
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database');
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database');
|
||||
|
||||
if ($user->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
||||
@@ -113,6 +113,10 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
||||
}
|
||||
|
||||
if ($user->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('backups.cancelStalled', 'stop-circle', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED', true);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_TITLE'), 'archive');
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_TITLE'), 'archive');
|
||||
ToolbarHelper::preferences('com_mokosuitebackup');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user