Compare commits
271 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a41c13f41 | |||
| 94ff15c128 | |||
| ea91b49e50 | |||
| 99d8e954bd | |||
| 28ca110d7b | |||
| 307067580c | |||
| 7f5f041588 | |||
| 129be7ef9d | |||
| 14392de078 | |||
| 8bdadb68c2 | |||
| bcc4054ced | |||
| 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 | |||
| 07fb4dcc24 | |||
| 21a4352b3b | |||
| 9d26f59f98 | |||
| 3488434f28 | |||
| f97cd30c95 | |||
| 836d1bc8b7 | |||
| 79b3caa35a | |||
| 6102c8f590 | |||
| 88e53c5698 | |||
| ec1c3486c5 | |||
| 3742477aef | |||
| 1c256bba7a | |||
| cd4dc6efd2 | |||
| 2e6b71ac97 | |||
| 5b4f84bad7 | |||
| 847bb9bd0f | |||
| ebcaf44b63 | |||
| 17b05f9a13 | |||
| bb8e4a258a | |||
| e6d646011a | |||
| 726291995c | |||
| 2ac4923d74 | |||
| adc4935587 | |||
| 8f7b747c59 | |||
| 42b7503d7b | |||
| 9ac8757a8c | |||
| ef3fde1c39 | |||
| 5750e71d15 | |||
| c8e022d46b | |||
| 21f2ba0eff | |||
| 821c4bae11 | |||
| e86c104276 | |||
| af2a1a2dae | |||
| c88b163de0 | |||
| 358a7eb68a | |||
| 898520d1db | |||
| e633d0cc0a | |||
| ff7418721d | |||
| 0b2b885163 | |||
| 6c47838b30 | |||
| 0f95cb6e9f | |||
| 1da2fdb856 | |||
| 4df70531e2 | |||
| 845b856cda | |||
| 633e9b7f1e | |||
| ec0b7eb8a4 | |||
| 7d119565da | |||
| 9db7331a72 | |||
| 32931c1e37 |
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "source/packages/MokoSuiteClient"]
|
||||||
|
path = source/packages/MokoSuiteClient
|
||||||
|
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
|
||||||
@@ -59,8 +59,8 @@ Joomla **package** with four sub-extensions:
|
|||||||
- **Attribution**: `Authored-by: Moko Consulting`
|
- **Attribution**: `Authored-by: Moko Consulting`
|
||||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||||
- **Minification**: handled at build time (CI)
|
- **Minification**: handled at build time (CI)
|
||||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
- **Wiki**: documentation lives in the MokoGitea wiki, not `docs/` files
|
||||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/mokoplatform/wiki/Home)
|
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki)
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
# VERSION: 09.02.00
|
# VERSION: 09.02.00
|
||||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||||
@@ -22,7 +22,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -34,7 +34,8 @@ jobs:
|
|||||||
if: >-
|
if: >-
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
!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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -52,7 +53,7 @@ jobs:
|
|||||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
git clone --depth 1 --branch main --quiet \
|
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
|
/tmp/mokocli
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
# PATH: /.mokogitea/workflows/auto-release.yml
|
||||||
# VERSION: 05.00.00
|
# VERSION: 05.01.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +=======================================================================+
|
# +=======================================================================+
|
||||||
@@ -27,7 +27,7 @@ name: "Universal: Build & Release"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, closed]
|
types: [opened, synchronize, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
@@ -52,7 +52,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
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_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
@@ -64,9 +64,14 @@ jobs:
|
|||||||
promote-rc:
|
promote-rc:
|
||||||
name: Promote to RC
|
name: Promote to RC
|
||||||
runs-on: release
|
runs-on: release
|
||||||
|
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||||
if: >-
|
if: >-
|
||||||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
|
(
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
(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')
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
)
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -74,6 +79,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
@@ -89,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
|
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
|
fi
|
||||||
rm -rf /tmp/mokocli
|
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
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
cd /tmp/mokocli
|
cd /tmp/mokocli
|
||||||
composer install --no-dev --no-interaction --quiet
|
composer install --no-dev --no-interaction --quiet
|
||||||
@@ -98,18 +104,46 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename branch to rc
|
- name: Rename branch to rc
|
||||||
run: |
|
run: |
|
||||||
php ${MOKO_CLI}/branch_rename.php \
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
PR="${{ github.event.pull_request.number }}"
|
||||||
--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
|
- name: Checkout rc and configure git
|
||||||
run: |
|
run: |
|
||||||
git fetch origin rc
|
git fetch origin rc
|
||||||
git checkout rc
|
git checkout rc
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||||
git config --local user.name "gitea-actions[bot]"
|
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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|
||||||
- name: Publish RC release
|
- name: Publish RC release
|
||||||
@@ -120,7 +154,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update RC release notes from CHANGELOG.md
|
- name: Update RC release notes from CHANGELOG.md
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Extract [Unreleased] section from changelog
|
# Extract [Unreleased] section from changelog
|
||||||
@@ -162,9 +196,13 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Build & Release Pipeline
|
name: Build & Release Pipeline
|
||||||
runs-on: release
|
runs-on: release
|
||||||
|
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||||
if: >-
|
if: >-
|
||||||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
|
(
|
||||||
github.event.pull_request.merged == true ||
|
github.event.pull_request.merged == true ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||||
|
)
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -172,11 +210,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Configure git for bot pushes
|
- name: Configure git for bot pushes
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||||
git config --local user.name "gitea-actions[bot]"
|
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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|
||||||
- name: Check for merge conflict markers
|
- name: Check for merge conflict markers
|
||||||
@@ -207,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
|
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
|
fi
|
||||||
rm -rf /tmp/mokocli
|
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
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
cd /tmp/mokocli
|
cd /tmp/mokocli
|
||||||
composer install --no-dev --no-interaction --quiet
|
composer install --no-dev --no-interaction --quiet
|
||||||
@@ -268,7 +307,7 @@ jobs:
|
|||||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
SEMVER_TAG="v${VERSION}"
|
SEMVER_TAG="v${VERSION}"
|
||||||
|
|
||||||
@@ -293,7 +332,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update release notes and promote changelog
|
- name: Update release notes and promote changelog
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Get the stable release info (version and ID)
|
# Get the stable release info (version and ID)
|
||||||
@@ -341,9 +380,12 @@ jobs:
|
|||||||
import sys
|
import sys
|
||||||
version, date = sys.argv[1], sys.argv[2]
|
version, date = sys.argv[1], sys.argv[2]
|
||||||
content = open('CHANGELOG.md').read()
|
content = open('CHANGELOG.md').read()
|
||||||
old = '## [Unreleased]'
|
marker = f'## [{version}]'
|
||||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
# Idempotent: only promote when this version header isn't already present,
|
||||||
content = content.replace(old, new, 1)
|
# otherwise re-runs (or same-version builds) create empty duplicate headers.
|
||||||
|
if marker not in content:
|
||||||
|
new = f'## [Unreleased]\n\n{marker} --- {date}'
|
||||||
|
content = content.replace('## [Unreleased]', new, 1)
|
||||||
open('CHANGELOG.md', 'w').write(content)
|
open('CHANGELOG.md', 'w').write(content)
|
||||||
" "$VERSION" "$DATE"
|
" "$VERSION" "$DATE"
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md
|
||||||
@@ -362,7 +404,7 @@ jobs:
|
|||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
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 \
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
@@ -387,11 +429,11 @@ jobs:
|
|||||||
&& echo "main branch pushed to GitHub mirror" \
|
&& echo "main branch pushed to GitHub mirror" \
|
||||||
|| echo "WARNING: GitHub mirror push failed"
|
|| 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'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Delete rc branch (ephemeral — created by promote-rc)
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
@@ -399,23 +441,15 @@ jobs:
|
|||||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||||
|
|
||||||
# Delete dev branch
|
# dev is reset from main by the dedicated "Cascade Main -> Dev" workflow
|
||||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
# (cascade-dev.yml), which runs after this release completes.
|
||||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: "Step 12: Create version branch from main"
|
- name: "Step 12: Create version branch from main"
|
||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
BRANCH_NAME="version/${VERSION}"
|
BRANCH_NAME="version/${VERSION}"
|
||||||
@@ -436,7 +470,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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 \
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
--branch dev --path . 2>&1 || true
|
--branch dev --path . 2>&1 || true
|
||||||
@@ -462,5 +496,5 @@ jobs:
|
|||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $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
|
fi
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: MokoStandards.Universal
|
# INGROUP: MokoStandards.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Delete feature branches after PR merge
|
# BRIEF: Delete feature branches after PR merge
|
||||||
@@ -33,7 +33,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
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 \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
|||||||
@@ -1,10 +1,106 @@
|
|||||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
#
|
||||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
name: "Cascade Main → Dev (DISABLED)"
|
#
|
||||||
on: workflow_dispatch
|
# 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:
|
jobs:
|
||||||
noop:
|
cascade:
|
||||||
|
name: Cascade main -> dev
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: MokoStandards.CI
|
# INGROUP: MokoStandards.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /.gitea/workflows/ci-generic.yml
|
# PATH: /.mokogitea/workflows/ci-generic.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||||
|
|
||||||
name: "Generic: Project CI"
|
name: "Generic: Project CI"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev/**
|
||||||
|
- rc/**
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -26,6 +32,8 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: Lint & Validate
|
name: Lint & Validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
|
||||||
|
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -124,6 +132,9 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: lint
|
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:
|
steps:
|
||||||
- name: Checkout
|
- 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
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: MokoStandards.Maintenance
|
# INGROUP: MokoStandards.Maintenance
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||||
# PATH: /.gitea/workflows/cleanup.yml
|
# PATH: /.mokogitea/workflows/cleanup.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
@@ -33,30 +33,30 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# 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')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for BRANCH in $BRANCHES; do
|
for BRANCH in $BRANCHES; do
|
||||||
# Skip protected branches
|
# Skip protected branches
|
||||||
case "$BRANCH" in
|
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
|
esac
|
||||||
|
|
||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
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
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
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)
|
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
|
# 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" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
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
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
|
||||||
# VERSION: 04.07.00
|
|
||||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
|
||||||
|
|
||||||
name: "Universal: Deploy to Dev (Manual)"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
clear_remote:
|
|
||||||
description: 'Delete all remote files before uploading'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: SFTP Deploy to Dev
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
php -v && composer --version
|
|
||||||
|
|
||||||
- name: Setup MokoStandards tools
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
|
||||||
run: |
|
|
||||||
git clone --depth 1 --branch main --quiet \
|
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check FTP configuration
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
|
||||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
|
||||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
|
||||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
REMOTE="${PATH_VAR%/}"
|
|
||||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
[ -z "$PORT" ] && PORT="22"
|
|
||||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.check.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
run: |
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
|
||||||
|
|
||||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
|
||||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
|
|
||||||
if [ -n "$SFTP_KEY" ]; then
|
|
||||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
|
||||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
|
||||||
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
|
||||||
else
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
|
||||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: MokoStandards.Security
|
# INGROUP: MokoStandards.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||||
# PATH: /templates/workflows/gitleaks.yml.template
|
# PATH: /.mokogitea/workflows/gitleaks.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.41.00
|
# VERSION: 02.57.03
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
@@ -19,7 +19,7 @@ permissions:
|
|||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-branch:
|
create-branch:
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create branch and comment
|
- name: Create branch and comment
|
||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
echo "Created branch: ${BRANCH}"
|
echo "Created branch: ${BRANCH}"
|
||||||
|
|
||||||
# Comment on issue with branch link
|
# 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\`\`\`"
|
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||||
|
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: MokoStandards.Notifications
|
# INGROUP: MokoStandards.Notifications
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||||
# PATH: /.gitea/workflows/notify.yml
|
# PATH: /.mokogitea/workflows/notify.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
|
|||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows:
|
workflows:
|
||||||
- "Joomla Build & Release"
|
- "Universal: Build & Release"
|
||||||
- "Joomla Extension CI"
|
- "Joomla: Extension CI"
|
||||||
- "Deploy"
|
- "Generic: Project CI"
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: moko-platform.CI
|
# INGROUP: mokocli.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
# PATH: /.mokogitea/workflows/pr-check.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: PR gate — branch policy + code validation before merge
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
|
|
||||||
@@ -47,15 +47,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
fix/*|bugfix/*)
|
fix/*|bugfix/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
patch/*)
|
patch/*)
|
||||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
hotfix/*)
|
hotfix/*)
|
||||||
@@ -86,7 +86,8 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`feature/*\` → \`dev\`" >> $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 "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -96,6 +97,80 @@ jobs:
|
|||||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
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 ──────────────────────────────────────────────────
|
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||||
gitleaks:
|
gitleaks:
|
||||||
name: Secret Scan
|
name: Secret Scan
|
||||||
@@ -126,6 +201,8 @@ jobs:
|
|||||||
validate:
|
validate:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
|
||||||
|
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -147,11 +224,12 @@ jobs:
|
|||||||
- name: Detect platform
|
- name: Detect platform
|
||||||
id: platform
|
id: platform
|
||||||
run: |
|
run: |
|
||||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
|
||||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
|
||||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
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"
|
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Detected platform: $PLATFORM"
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
@@ -272,7 +350,7 @@ jobs:
|
|||||||
joomla)
|
joomla)
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||||
if [ -z "$MANIFEST" ]; then
|
if [ -z "$MANIFEST" ]; then
|
||||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
echo "::warning::No Joomla manifest found (MokoSuite site)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Manifest: ${MANIFEST}"
|
echo "Manifest: ${MANIFEST}"
|
||||||
@@ -285,7 +363,7 @@ jobs:
|
|||||||
# Block legacy raw/branch update server URLs on MokoGitea
|
# Block legacy raw/branch update server URLs on MokoGitea
|
||||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||||
if [ -n "$RAW_URLS" ]; then
|
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"
|
echo "$RAW_URLS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -492,43 +570,33 @@ jobs:
|
|||||||
name: Build RC Package
|
name: Build RC Package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [branch-policy, validate]
|
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:
|
steps:
|
||||||
- name: Trigger RC pre-release
|
- name: Trigger RC pre-release
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
BRANCH: ${{ github.head_ref }}
|
BRANCH: ${{ github.head_ref }}
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
run: |
|
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 "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||||
report-issues:
|
report-issues:
|
||||||
name: Report Issues
|
name: Report Issues
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [branch-policy, validate]
|
needs: [branch-policy, validate]
|
||||||
if: >-
|
if: >-
|
||||||
always() &&
|
always() &&
|
||||||
needs.validate.result == 'failure'
|
needs.validate.result == 'failure'
|
||||||
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
sparse-checkout: automation/ci-issue-reporter.sh
|
gate: "PR Validation"
|
||||||
sparse-checkout-cone-mode: false
|
workflow: "PR Check"
|
||||||
|
severity: error
|
||||||
- name: "File issue for PR validation failure"
|
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||||
env:
|
secrets: inherit
|
||||||
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."
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
# PATH: /.mokogitea/workflows/pre-release.yml
|
||||||
# VERSION: 05.01.00
|
# VERSION: 05.02.00
|
||||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
name: "Universal: Pre-Release"
|
||||||
@@ -48,9 +48,13 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||||
runs-on: release
|
runs-on: release
|
||||||
|
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||||
if: >-
|
if: >-
|
||||||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
|
(
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'push'
|
github.event_name == 'push'
|
||||||
|
)
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -59,6 +63,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
ref: ${{ github.ref_name }}
|
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
|
- name: Setup mokocli tools
|
||||||
env:
|
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
|
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
|
fi
|
||||||
rm -rf /tmp/mokocli
|
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
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
@@ -147,8 +156,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Commit version bump
|
# Commit version bump
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
|
||||||
git config --local user.name "gitea-actions[bot]"
|
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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
git add -A
|
git add -A
|
||||||
git diff --cached --quiet || {
|
git diff --cached --quiet || {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Universal
|
# INGROUP: mokocli.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
@@ -25,16 +25,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >-
|
if: >-
|
||||||
github.event.pull_request.merged == false &&
|
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:
|
steps:
|
||||||
- name: Rename branch
|
- 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: |
|
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/}"
|
SUFFIX="${BRANCH#rc/}"
|
||||||
DEV_BRANCH="dev/${SUFFIX}"
|
DEV_BRANCH="dev/${SUFFIX}"
|
||||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
|
||||||
|
|
||||||
# Create dev/ branch from rc/ branch
|
# Create dev/ branch from rc/ branch
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||||
@@ -42,25 +51,22 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||||
"${API}" 2>/dev/null || true)
|
"${API}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "201" ]; then
|
if [ "$STATUS" = "201" ]; then
|
||||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete rc/ branch
|
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "204" ]; then
|
if [ "$STATUS" = "204" ]; then
|
||||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Validation
|
# INGROUP: mokocli.Validation
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
# PATH: /.mokogitea/workflows/repo-health.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
# 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)
|
- name: Check actor permission (admin only)
|
||||||
id: perm
|
id: perm
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
ACTOR: ${{ github.actor }}
|
ACTOR: ${{ github.actor }}
|
||||||
run: |
|
run: |
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
|
|
||||||
# Hardcoded authorized users — always allowed
|
# Hardcoded authorized users — always allowed
|
||||||
case "$ACTOR" in
|
case "$ACTOR" in
|
||||||
jmiller|gitea-actions[bot])
|
jmiller|mokogitea-actions[bot])
|
||||||
ALLOWED=true
|
ALLOWED=true
|
||||||
PERMISSION=admin
|
PERMISSION=admin
|
||||||
METHOD="hardcoded allowlist"
|
METHOD="hardcoded allowlist"
|
||||||
@@ -671,42 +671,30 @@ jobs:
|
|||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Issue Reporter — file issues for failed gates
|
# Issue Reporter — file issues for failed gates
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
report-issues:
|
report-scripts:
|
||||||
name: "Report Issues"
|
name: "Report: Scripts Governance"
|
||||||
runs-on: ubuntu-latest
|
needs: [access_check, scripts_governance]
|
||||||
needs: [access_check, scripts_governance, repo_health]
|
|
||||||
if: >-
|
if: >-
|
||||||
always() &&
|
always() &&
|
||||||
(needs.scripts_governance.result == 'failure' ||
|
needs.scripts_governance.result == 'failure'
|
||||||
needs.repo_health.result == 'failure')
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
sparse-checkout: automation/ci-issue-reporter.sh
|
gate: "Scripts Governance"
|
||||||
sparse-checkout-cone-mode: false
|
workflow: "Repo Health"
|
||||||
|
severity: error
|
||||||
|
details: "Scripts directory policy violations detected. Review required and allowed directories."
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
- name: "File issues for failed gates"
|
report-health:
|
||||||
env:
|
name: "Report: Repository Health"
|
||||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
needs: [access_check, repo_health]
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
if: >-
|
||||||
run: |
|
always() &&
|
||||||
chmod +x automation/ci-issue-reporter.sh
|
needs.repo_health.result == 'failure'
|
||||||
REPORTER="./automation/ci-issue-reporter.sh"
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
WF="Repo Health"
|
with:
|
||||||
|
gate: "Repository Health"
|
||||||
report_gate() {
|
workflow: "Repo Health"
|
||||||
local gate="$1" result="$2" details="$3"
|
severity: error
|
||||||
if [ "$result" = "failure" ]; then
|
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
secrets: inherit
|
||||||
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."
|
|
||||||
|
|||||||
@@ -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
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: MokoGitea.Workflow
|
||||||
# INGROUP: mokocli.Universal
|
# 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
|
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||||
# VERSION: 01.01.00
|
# VERSION: 01.01.00
|
||||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
name: "Universal: Workflow Sync Trigger"
|
name: "Universal: Workflow Sync Trigger"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [closed]
|
types: [closed]
|
||||||
branches:
|
branches:
|
||||||
@@ -26,8 +27,10 @@ jobs:
|
|||||||
name: Sync workflows to live repos
|
name: Sync workflows to live repos
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >-
|
if: >-
|
||||||
github.event.pull_request.merged == true &&
|
startsWith(github.event.repository.name, 'Template-') &&
|
||||||
!contains(github.event.pull_request.title, '[skip sync]')
|
(github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.pull_request.merged == true &&
|
||||||
|
!contains(github.event.pull_request.title, '[skip sync]')))
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Determine platform from repo name
|
- name: Determine platform from repo name
|
||||||
@@ -49,8 +52,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
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
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+29
-31
@@ -1,38 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.41.00] --- 2026-06-23
|
## [02.57.00] --- 2026-07-05
|
||||||
|
|
||||||
## [01.41.00] --- 2026-06-23
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Multi-remote storage: new `#__mokosuitebackup_remotes` table for multiple destinations per profile (#97)
|
- `BackupRunner` service — a single synchronous entry point for backups that unifies final status and notification gating across the pre-update, web-cron, pre-install, scheduled-task and CLI triggers (delegates to `BackupEngine`; no double-notify). Returns a normalized result including the real complete/warning/fail status. Dashboard "Backup Now" (AJAX) unchanged; installer progress popup deferred to #196. (#214)
|
||||||
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit view
|
|
||||||
- Engine integration: BackupEngine and SteppedBackupEngine upload to all enabled destinations
|
|
||||||
- Migration SQL: auto-migrates existing SFTP/S3/GDrive/FTP configs to new table
|
|
||||||
- Backward compatibility: falls back to legacy single-remote columns if remotes table is empty
|
|
||||||
- Secrets masked in API responses, merged from DB on save to prevent leakage
|
|
||||||
|
|
||||||
## [01.40.00] --- 2026-06-23
|
|
||||||
|
|
||||||
|
|
||||||
## [01.40.00] --- 2026-06-23
|
|
||||||
|
|
||||||
## [01.39.01] --- 2026-06-23
|
|
||||||
|
|
||||||
## [01.39.01] --- 2026-06-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- MokoRestore: post-restore reset options — passwords, hits, versions, sessions, cache (#131)
|
|
||||||
- MokoRestore: per-table conflict resolution — replace, skip, merge, data-only per table (#132)
|
|
||||||
- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users"
|
|
||||||
- MokoRestore: auto-detect sanitized passwords and prompt for reset
|
|
||||||
- Data sanitization: passwords, emails, sessions in backup profile settings (#129)
|
|
||||||
- Manual purge: delete all backups older than a selected date with count preview (#119)
|
|
||||||
- CPanel admin dashboard module with backup status, quick actions, and profile buttons (#105)
|
|
||||||
- 7z archive format via system 7za/7z binary with optional password encryption (#122)
|
|
||||||
- SFTP remote file browser: browse remote server directories to select backup path (#98)
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
|
- Release automation: the changelog-promote step is now idempotent — it only inserts a `## [version]` header when that version isn't already present, preventing the empty, duplicated version headers that repeated/same-version builds were producing. (workflow)
|
||||||
- MokoRestore: temporary password is now randomly generated (not hardcoded "changeme")
|
|
||||||
|
## [02.56.11] --- 2026-07-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## [02.56.08] --- 2026-07-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## [02.56.07] --- 2026-07-05
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ Thank you for your interest in contributing to MokoSuiteBackup.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Fork the repository on Gitea
|
1. Fork the repository on MokoGitea
|
||||||
2. Create a feature branch from `dev` (`feature/your-feature`)
|
2. Create a feature branch from `dev` (`feature/your-feature`)
|
||||||
3. Make your changes following the coding standards below
|
3. Make your changes following the coding standards below
|
||||||
4. Submit a pull request targeting `dev`
|
4. Submit a pull request targeting `dev`
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Package** | `pkg_mokosuitebackup` |
|
| **Package** | `pkg_mokosuitebackup` |
|
||||||
| **Type** | Joomla Package (8 sub-extensions) |
|
| **Type** | Joomla Package (9 sub-extensions + MokoSuiteClient) |
|
||||||
| **Joomla** | 6.x+ |
|
| **Joomla** | 6.x+ |
|
||||||
| **PHP** | 8.1+ |
|
| **PHP** | 8.1+ |
|
||||||
| **License** | GPL-3.0-or-later |
|
| **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
|
- Stepped AJAX engine prevents timeout on shared hosting
|
||||||
- AES-256 ZIP encryption with configurable password
|
- AES-256 ZIP encryption with configurable password
|
||||||
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
|
- 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
|
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
|
||||||
|
|
||||||
### Content Snapshots
|
### Content Snapshots
|
||||||
@@ -30,7 +31,8 @@ Full-site backup and restore for Joomla — database, files, and configuration.
|
|||||||
- Scheduled snapshot task via com_scheduler
|
- Scheduled snapshot task via com_scheduler
|
||||||
|
|
||||||
### Remote Storage
|
### 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)
|
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
|
||||||
- Google Drive with OAuth2 and resumable uploads
|
- Google Drive with OAuth2 and resumable uploads
|
||||||
- Graceful degradation — local backup preserved if upload fails
|
- 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
|
- Snapshots: create, list, restore, delete, download
|
||||||
- Profile credentials masked in API responses
|
- 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
|
## Installation
|
||||||
|
|
||||||
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
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.57.03
|
||||||
|
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 30a6b53222
@@ -15,5 +15,6 @@
|
|||||||
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
|
<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.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
|
||||||
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
||||||
|
<action name="mokosuitebackup.backup.cancel" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL" />
|
||||||
</section>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class JsonapiView extends BaseApiView
|
|||||||
'origin',
|
'origin',
|
||||||
'backup_type',
|
'backup_type',
|
||||||
'archivename',
|
'archivename',
|
||||||
'absolute_path',
|
|
||||||
'total_size',
|
'total_size',
|
||||||
'db_size',
|
'db_size',
|
||||||
'files_count',
|
'files_count',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
type="sql"
|
type="sql"
|
||||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
|
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"
|
default="1"
|
||||||
>
|
>
|
||||||
<option value="1">Default Backup Profile (#1)</option>
|
<option value="1">Default Backup Profile (#1)</option>
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
|
||||||
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
|
||||||
default="https://ntfy.sh"
|
default="https://ntfy.mokoconsulting.tech"
|
||||||
filter="url"
|
filter="url"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
>
|
>
|
||||||
<option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option>
|
<option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option>
|
||||||
<option value="complete">COM_MOKOJOOMBACKUP_STATUS_COMPLETE</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="running">COM_MOKOJOOMBACKUP_STATUS_RUNNING</option>
|
||||||
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
|
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
|
||||||
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
|
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
|
||||||
|
|||||||
@@ -24,10 +24,9 @@
|
|||||||
name="fullordering"
|
name="fullordering"
|
||||||
type="list"
|
type="list"
|
||||||
label="JGLOBAL_SORT_BY"
|
label="JGLOBAL_SORT_BY"
|
||||||
default="a.ordering ASC"
|
default="a.id ASC"
|
||||||
onchange="this.form.submit();"
|
onchange="this.form.submit();"
|
||||||
>
|
>
|
||||||
<option value="a.ordering ASC">JFIELD_ORDERING_LABEL_ASC</option>
|
|
||||||
<option value="a.title ASC">COM_MOKOJOOMBACKUP_HEADING_TITLE_ASC</option>
|
<option value="a.title ASC">COM_MOKOJOOMBACKUP_HEADING_TITLE_ASC</option>
|
||||||
<option value="a.title DESC">COM_MOKOJOOMBACKUP_HEADING_TITLE_DESC</option>
|
<option value="a.title DESC">COM_MOKOJOOMBACKUP_HEADING_TITLE_DESC</option>
|
||||||
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
||||||
|
|||||||
@@ -93,6 +93,16 @@
|
|||||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||||
</field>
|
</field>
|
||||||
|
<field
|
||||||
|
name="restore_script_name"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME_DESC"
|
||||||
|
default="restore.php"
|
||||||
|
maxlength="128"
|
||||||
|
filter="string"
|
||||||
|
showon="include_mokorestore!:0"
|
||||||
|
/>
|
||||||
<field
|
<field
|
||||||
name="encryption_password"
|
name="encryption_password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -164,12 +174,6 @@
|
|||||||
<option value="1">JPUBLISHED</option>
|
<option value="1">JPUBLISHED</option>
|
||||||
<option value="0">JUNPUBLISHED</option>
|
<option value="0">JUNPUBLISHED</option>
|
||||||
</field>
|
</field>
|
||||||
<field
|
|
||||||
name="ordering"
|
|
||||||
type="number"
|
|
||||||
label="JFIELD_ORDERING_LABEL"
|
|
||||||
default="0"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="filters" label="COM_MOKOJOOMBACKUP_FIELDSET_FILTERS">
|
<fieldset name="filters" label="COM_MOKOJOOMBACKUP_FIELDSET_FILTERS">
|
||||||
@@ -202,25 +206,6 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
<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
|
<field
|
||||||
name="remote_keep_local"
|
name="remote_keep_local"
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -232,81 +217,6 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</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>
|
||||||
|
|
||||||
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
||||||
@@ -404,157 +314,4 @@
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</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>
|
</form>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
; @license GPL-3.0-or-later
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||||
|
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options"
|
||||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||||
|
|
||||||
; Submenu
|
; Submenu
|
||||||
@@ -21,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."
|
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
|
; Dashboard view
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard"
|
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="Dashboard"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
|
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
|
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
|
||||||
@@ -41,14 +43,16 @@ COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
|
|||||||
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
|
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
|
||||||
|
|
||||||
; Backups view
|
; 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_BACKUPS_TABLE_CAPTION="Table of backup records"
|
||||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||||
COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
||||||
|
|
||||||
; Backup detail view
|
; Backup detail view
|
||||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Detail"
|
||||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||||
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||||
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||||
@@ -72,7 +76,7 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
|||||||
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
||||||
|
|
||||||
; Profiles view
|
; Profiles view
|
||||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles"
|
||||||
COM_MOKOJOOMBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles"
|
COM_MOKOJOOMBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles"
|
||||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||||
@@ -127,29 +131,31 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)"
|
|||||||
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
||||||
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
|
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups."
|
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="AES-256 encryption password. Leave blank for no encryption. Required to restore."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [HOST], [DATE], [YEAR], [MONTH], [DAY], [PROFILE_NAME], [SITE_NAME], [TYPE]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Where backups are stored. Use placeholders like [HOME]/backups for portability. Click the ? icon for full documentation."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [HOST] hostname, [DATE] Ymd, [TIME] His, [DATETIME] Ymd_His, [YEAR] [MONTH] [DAY] [HOUR] [MINUTE] [SECOND], [PROFILE_ID], [PROFILE_NAME], [SITE_NAME], [TYPE], [RANDOM]."
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template (without extension). Click the placeholder buttons below to insert tokens."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="None: no restore script. Wrapped: bundled inside the ZIP. Standalone: separate restore.php file (ideal for remote servers)."
|
||||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME="Restore Script Filename"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME_DESC="Custom filename for the restore script. Must end in .php. Use a non-obvious name to reduce discoverability on remote servers (e.g. moko-install-xyz.php)."
|
||||||
|
|
||||||
; Data Sanitization
|
; Data Sanitization
|
||||||
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace password hashes with invalid values. Users must reset passwords after restore. For demos, staging, or GDPR."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace emails with dummy values. Prevents accidental emails from cloned sites. Super admin preserved if enabled above."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude session data. Logs out all users on restore, prevents session hijacking. Enabled by default."
|
||||||
|
|
||||||
; Exclusion filter fields
|
; Exclusion filter fields
|
||||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||||
@@ -202,6 +208,7 @@ COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)"
|
|||||||
|
|
||||||
; Status labels
|
; Status labels
|
||||||
COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete"
|
COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete"
|
||||||
|
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
|
||||||
COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running"
|
COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running"
|
||||||
COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed"
|
COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed"
|
||||||
COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
|
COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
|
||||||
@@ -244,9 +251,9 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails.
|
|||||||
; Retention
|
; Retention
|
||||||
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
|
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)"
|
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="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_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"
|
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
|
||||||
@@ -275,9 +282,9 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
|
|||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||||
@@ -445,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_COMPARE_DESC="Allows users to compare two backup records side-by-side."
|
||||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
|
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_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
|
; Snapshot ACL
|
||||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||||
@@ -495,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_SUCCESS="%d backup(s) purged successfully."
|
||||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
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)
|
; Remote Destinations (multi-remote)
|
||||||
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
||||||
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
; @license GPL-3.0-or-later
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||||
|
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
; @license GPL-3.0-or-later
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||||
|
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
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="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_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_LAST_BACKUP="Last Backup"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
|
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_SCHEDULED_TASKS="Scheduled Tasks"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
||||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Records"
|
||||||
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles"
|
||||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
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_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
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_INVALID_DATE="Invalid date. Please select a valid date."
|
||||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
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
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||||
|
COM_MOKOJOOMBACKUP_SHORT="Backup"
|
||||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<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>
|
<name>MokoSuiteBackup</name>
|
||||||
<version>01.41.00</version>
|
<version>02.57.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
<license>GPL-3.0-or-later</license>
|
<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>
|
<namespace path="src">Joomla\Component\MokoSuiteBackup</namespace>
|
||||||
|
|
||||||
@@ -37,20 +47,20 @@
|
|||||||
</update>
|
</update>
|
||||||
|
|
||||||
<administration>
|
<administration>
|
||||||
<menu img="class:archive">COM_MOKOJOOMBACKUP</menu>
|
<menu img="class:archive">Backup</menu>
|
||||||
<submenu>
|
<submenu>
|
||||||
<menu link="option=com_mokosuitebackup&view=dashboard"
|
<menu link="option=com_mokosuitebackup&view=dashboard"
|
||||||
img="class:home"
|
img="class:home"
|
||||||
alt="Dashboard">COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD</menu>
|
alt="Dashboard">Dashboard</menu>
|
||||||
<menu link="option=com_mokosuitebackup&view=backups"
|
<menu link="option=com_mokosuitebackup&view=backups"
|
||||||
img="class:database"
|
img="class:database"
|
||||||
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
|
alt="Backups">Backup Records</menu>
|
||||||
<menu link="option=com_mokosuitebackup&view=snapshots"
|
<menu link="option=com_mokosuitebackup&view=snapshots"
|
||||||
img="class:camera"
|
img="class:camera"
|
||||||
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
|
alt="Snapshots">Content Snapshots</menu>
|
||||||
<menu link="option=com_mokosuitebackup&view=profiles"
|
<menu link="option=com_mokosuitebackup&view=profiles"
|
||||||
img="class:cog"
|
img="class:cog"
|
||||||
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
|
alt="Profiles">Backup Profiles</menu>
|
||||||
</submenu>
|
</submenu>
|
||||||
<files folder=".">
|
<files folder=".">
|
||||||
<filename>access.xml</filename>
|
<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_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns 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',
|
`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',
|
`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)',
|
`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',
|
`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',
|
`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',
|
`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',
|
`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_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_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
|
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`retention_days` 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 '0 = use global default',
|
`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_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_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)',
|
`ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)',
|
||||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
`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',
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@@ -65,7 +39,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
|
|||||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
||||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
`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',
|
`origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled',
|
||||||
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
|
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
|
||||||
`archivename` VARCHAR(512) NOT NULL DEFAULT '',
|
`archivename` VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
@@ -113,14 +87,13 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
|||||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||||
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
|
|
||||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_profile` (`profile_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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
-- 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`,
|
`id`, `title`, `description`, `backup_type`,
|
||||||
`archive_format`, `compression_level`, `split_size`, `backup_dir`,
|
`archive_format`, `compression_level`, `split_size`, `backup_dir`,
|
||||||
`exclude_dirs`, `exclude_files`, `exclude_tables`,
|
`exclude_dirs`, `exclude_files`, `exclude_tables`,
|
||||||
`published`, `ordering`, `created`, `modified`
|
`published`, `created`, `modified`
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1, 'Default Backup Profile', 'Full site backup with default settings', 'full',
|
1, 'Default Backup Profile', 'Full site backup with default settings', 'full',
|
||||||
'zip', 5, 0, '[DEFAULT_DIR]',
|
'zip', 5, 0, '[DEFAULT_DIR]',
|
||||||
'administrator/components/com_mokosuitebackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
|
'administrator/components/com_mokosuitebackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
|
||||||
'.gitignore\n.htaccess.bak',
|
'.gitignore\n.htaccess.bak',
|
||||||
'#__session',
|
'#__session',
|
||||||
1, 1, NOW(), NOW()
|
1, NOW(), NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||||
|
DROP TABLE IF EXISTS `#__mokosuitebackup_snapshots`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.43.11 — no schema changes */
|
||||||
@@ -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.57.00 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 02.57.03 — no schema changes */
|
||||||
@@ -84,6 +84,67 @@ class AjaxController extends BaseController
|
|||||||
$this->sendJson($result);
|
$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.
|
* Browse server directories for the folder picker field.
|
||||||
* POST: task=ajax.browseDir&path=/some/path
|
* POST: task=ajax.browseDir&path=/some/path
|
||||||
@@ -451,7 +512,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
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']);
|
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -747,7 +808,7 @@ class AjaxController extends BaseController
|
|||||||
->select('COUNT(*)')
|
->select('COUNT(*)')
|
||||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
->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);
|
$db->setQuery($query);
|
||||||
$count = (int) $db->loadResult();
|
$count = (int) $db->loadResult();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -924,11 +985,11 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode JSON config and mask secrets
|
// Decode JSON params and mask secrets
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
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
|
// Mask sensitive fields so they never leave the server in list views
|
||||||
$masked = $this->maskSecrets($config, $row->type);
|
$masked = $this->maskSecrets($config, $row->type);
|
||||||
@@ -939,8 +1000,7 @@ class AjaxController extends BaseController
|
|||||||
'title' => $row->title,
|
'title' => $row->title,
|
||||||
'type' => $row->type,
|
'type' => $row->type,
|
||||||
'enabled' => (int) $row->enabled,
|
'enabled' => (int) $row->enabled,
|
||||||
'keep_local' => (int) $row->keep_local,
|
'params' => $masked,
|
||||||
'config' => $masked,
|
|
||||||
'ordering' => (int) $row->ordering,
|
'ordering' => (int) $row->ordering,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -971,7 +1031,6 @@ class AjaxController extends BaseController
|
|||||||
$title = trim($this->input->getString('remote_title', ''));
|
$title = trim($this->input->getString('remote_title', ''));
|
||||||
$type = $this->input->getCmd('remote_type', 'sftp');
|
$type = $this->input->getCmd('remote_type', 'sftp');
|
||||||
$enabled = $this->input->getInt('remote_enabled', 1);
|
$enabled = $this->input->getInt('remote_enabled', 1);
|
||||||
$keepLocal = $this->input->getInt('remote_keep_local', 1);
|
|
||||||
$configRaw = $this->input->getString('remote_config', '{}');
|
$configRaw = $this->input->getString('remote_config', '{}');
|
||||||
|
|
||||||
if (!$profileId) {
|
if (!$profileId) {
|
||||||
@@ -1019,9 +1078,7 @@ class AjaxController extends BaseController
|
|||||||
$table->title = $title;
|
$table->title = $title;
|
||||||
$table->type = $type;
|
$table->type = $type;
|
||||||
$table->enabled = $enabled ? 1 : 0;
|
$table->enabled = $enabled ? 1 : 0;
|
||||||
$table->keep_local = $keepLocal ? 1 : 0;
|
$table->params = json_encode($config);
|
||||||
$table->config = json_encode($config);
|
|
||||||
|
|
||||||
if (!$table->check() || !$table->store()) {
|
if (!$table->check() || !$table->store()) {
|
||||||
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
$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
|
private function maskSecrets(array $config, string $type): array
|
||||||
{
|
{
|
||||||
$secrets = [
|
$secrets = [
|
||||||
|
'ftp' => ['password'],
|
||||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||||
's3' => ['secret_key'],
|
's3' => ['secret_key'],
|
||||||
'google_drive' => ['client_secret', 'refresh_token'],
|
'google_drive' => ['client_secret', 'refresh_token'],
|
||||||
@@ -1166,6 +1224,7 @@ class AjaxController extends BaseController
|
|||||||
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
||||||
{
|
{
|
||||||
$secrets = [
|
$secrets = [
|
||||||
|
'ftp' => ['password'],
|
||||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||||
's3' => ['secret_key'],
|
's3' => ['secret_key'],
|
||||||
'google_drive' => ['client_secret', 'refresh_token'],
|
'google_drive' => ['client_secret', 'refresh_token'],
|
||||||
@@ -1190,7 +1249,7 @@ class AjaxController extends BaseController
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('config'))
|
->select($db->quoteName('params'))
|
||||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
->where($db->quoteName('id') . ' = ' . $id);
|
->where($db->quoteName('id') . ' = ' . $id);
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
@@ -1208,184 +1267,6 @@ class AjaxController extends BaseController
|
|||||||
return $config;
|
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.
|
* Send a JSON response and close the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class BackupsController extends AdminController
|
|||||||
->select($db->quoteName('id'))
|
->select($db->quoteName('id'))
|
||||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
->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);
|
$db->setQuery($query);
|
||||||
$ids = $db->loadColumn();
|
$ids = $db->loadColumn();
|
||||||
|
|
||||||
@@ -235,6 +235,76 @@ class BackupsController extends AdminController
|
|||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
$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.
|
* No-op target for the purge toolbar button.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -228,28 +228,12 @@ class AkeebaImporter
|
|||||||
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
|
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
|
||||||
'exclude_files' => implode("\n", $filters['exclude_files']),
|
'exclude_files' => implode("\n", $filters['exclude_files']),
|
||||||
'exclude_tables' => implode("\n", $filters['exclude_tables']),
|
'exclude_tables' => implode("\n", $filters['exclude_tables']),
|
||||||
'remote_storage' => $this->mapRemoteStorage($config),
|
// Remote storage is no longer stored on the profile — it lives in
|
||||||
'ftp_host' => $config['engine.postproc.ftp.host'] ?? '',
|
// #__mokosuitebackup_remotes. Akeeba remote settings are not imported;
|
||||||
'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21),
|
// re-add remote destinations on the profile's Remote tab after import.
|
||||||
'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_keep_local' => 1,
|
'remote_keep_local' => 1,
|
||||||
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
|
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
|
||||||
'published' => 1,
|
'published' => 1,
|
||||||
'ordering' => (int) $akProfile->id,
|
|
||||||
'created' => $now,
|
'created' => $now,
|
||||||
'modified' => $now,
|
'modified' => $now,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -259,14 +259,14 @@ class BackupEngine
|
|||||||
|
|
||||||
// Step 2.5: MokoRestore script (if enabled)
|
// Step 2.5: MokoRestore script (if enabled)
|
||||||
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||||
|
$restoreScriptName = $profile->restore_script_name ?? 'restore.php';
|
||||||
$restoreScriptPath = '';
|
$restoreScriptPath = '';
|
||||||
|
|
||||||
if ($mokoRestoreMode === '1') {
|
if ($mokoRestoreMode === '1') {
|
||||||
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
|
||||||
$this->log('Wrapping with MokoRestore script...');
|
$this->log('Wrapping with MokoRestore script...');
|
||||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
MokoRestore::wrap($archivePath, $mokoRestorePath, $restoreScriptName);
|
||||||
|
|
||||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||||
@@ -278,11 +278,11 @@ class BackupEngine
|
|||||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||||
} elseif ($mokoRestoreMode === 'standalone') {
|
} elseif ($mokoRestoreMode === 'standalone') {
|
||||||
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||||
$this->log('Generating standalone restore.php...');
|
$this->log('Generating standalone ' . $restoreScriptName . '...');
|
||||||
$restoreScriptPath = $this->backupDir . '/restore.php';
|
$restoreScriptPath = $this->backupDir . '/' . $restoreScriptName;
|
||||||
MokoRestore::generateStandalone($restoreScriptPath);
|
MokoRestore::generateStandalone($restoreScriptPath);
|
||||||
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
$this->log('Standalone ' . $restoreScriptName . ' generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
@@ -303,9 +303,8 @@ class BackupEngine
|
|||||||
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||||
$this->log(' Upload complete: ' . $result['message']);
|
$this->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
/* Upload standalone restore.php if in standalone mode */
|
|
||||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
$uploader->upload($restoreScriptPath, 'restore.php');
|
$uploader->upload($restoreScriptPath, basename($restoreScriptPath));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
@@ -322,48 +321,6 @@ class BackupEngine
|
|||||||
@unlink($archivePath);
|
@unlink($archivePath);
|
||||||
$this->log('Local copy removed (remote_keep_local = off)');
|
$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']);
|
|
||||||
|
|
||||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
|
||||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
|
||||||
$this->log('Uploading standalone restore.php...');
|
|
||||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
|
||||||
|
|
||||||
if ($restoreUpload['success']) {
|
|
||||||
$this->log('Standalone restore.php uploaded');
|
|
||||||
} else {
|
|
||||||
$this->log('WARNING: restore.php 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
|
// Write log file alongside the archive
|
||||||
@@ -376,7 +333,7 @@ class BackupEngine
|
|||||||
// Final record update (includes fields needed by NotificationSender)
|
// Final record update (includes fields needed by NotificationSender)
|
||||||
$update = (object) [
|
$update = (object) [
|
||||||
'id' => $recordId,
|
'id' => $recordId,
|
||||||
'status' => 'complete',
|
'status' => $uploadFailed ? 'warning' : 'complete',
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
'backup_type' => $profile->backup_type,
|
'backup_type' => $profile->backup_type,
|
||||||
'archivename' => $archiveName,
|
'archivename' => $archiveName,
|
||||||
@@ -404,6 +361,17 @@ class BackupEngine
|
|||||||
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
|
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
|
// Dispatch event for actionlog and other listeners
|
||||||
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
||||||
|
|
||||||
@@ -520,23 +488,7 @@ class BackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate remote uploader based on the storage type.
|
* Create a remote uploader from JSON params.
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Builds a fake profile-like object from the params array so the existing
|
* Builds a fake profile-like object from the params array so the existing
|
||||||
* uploader constructors work without modification.
|
* uploader constructors work without modification.
|
||||||
@@ -548,7 +500,16 @@ class BackupEngine
|
|||||||
*/
|
*/
|
||||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
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) {
|
return match ($type) {
|
||||||
'ftp' => new FtpUploader($fake),
|
'ftp' => new FtpUploader($fake),
|
||||||
@@ -561,18 +522,9 @@ class BackupEngine
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load enabled remote destinations for a profile from the remotes table.
|
* 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
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
@@ -582,10 +534,6 @@ class BackupEngine
|
|||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
|
|
||||||
return $db->loadObjectList() ?: [];
|
return $db->loadObjectList() ?: [];
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -598,7 +546,7 @@ class BackupEngine
|
|||||||
->select($db->quoteName('manifest'))
|
->select($db->quoteName('manifest'))
|
||||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
->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('manifest') . ' != ' . $db->quote(''))
|
||||||
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
||||||
->order($db->quoteName('backupstart') . ' DESC');
|
->order($db->quoteName('backupstart') . ' DESC');
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ class DatabaseDumper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
$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");
|
fwrite($fp, $createSql . ";\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ use Joomla\CMS\Factory;
|
|||||||
|
|
||||||
class DatabaseImporter
|
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.
|
* Import a SQL dump file into the database.
|
||||||
*
|
*
|
||||||
@@ -31,6 +38,8 @@ class DatabaseImporter
|
|||||||
*/
|
*/
|
||||||
public function import(string $sqlFile): int
|
public function import(string $sqlFile): int
|
||||||
{
|
{
|
||||||
|
$this->errors = [];
|
||||||
|
|
||||||
if (!is_file($sqlFile) || !is_readable($sqlFile)) {
|
if (!is_file($sqlFile) || !is_readable($sqlFile)) {
|
||||||
throw new \RuntimeException('SQL file not readable: ' . $sqlFile);
|
throw new \RuntimeException('SQL file not readable: ' . $sqlFile);
|
||||||
}
|
}
|
||||||
@@ -97,8 +106,10 @@ class DatabaseImporter
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Log but don't abort — some statements may fail on
|
// Log but don't abort — some statements may fail on
|
||||||
// different MySQL versions (e.g. charset differences)
|
// 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());
|
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
|
||||||
|
$this->errors[] = $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +126,7 @@ class DatabaseImporter
|
|||||||
$statementsExecuted++;
|
$statementsExecuted++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
|
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
|
||||||
|
$this->errors[] = $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -123,4 +135,24 @@ class DatabaseImporter
|
|||||||
|
|
||||||
return $statementsExecuted;
|
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 !== [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,25 +35,36 @@ class MokoRestore
|
|||||||
*
|
*
|
||||||
* @return string Path to the wrapped archive
|
* @return string Path to the wrapped archive
|
||||||
*/
|
*/
|
||||||
public static function wrap(string $backupArchive, string $outputPath): string
|
public static function wrap(string $backupArchive, string $outputPath, string $scriptName = 'restore.php'): string
|
||||||
{
|
{
|
||||||
|
$scriptName = self::sanitizeScriptName($scriptName);
|
||||||
|
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
|
|
||||||
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||||
throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
|
throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the standalone restore script
|
$zip->addFromString($scriptName, self::generateRestoreScript());
|
||||||
$zip->addFromString('restore.php', self::generateRestoreScript());
|
|
||||||
|
|
||||||
// Add the original backup as a nested ZIP
|
|
||||||
$zip->addFile($backupArchive, 'site-backup.zip');
|
$zip->addFile($backupArchive, 'site-backup.zip');
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
return $outputPath;
|
return $outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function sanitizeScriptName(string $name): string
|
||||||
|
{
|
||||||
|
$name = basename(trim($name));
|
||||||
|
|
||||||
|
if ($name === '' || !str_ends_with(strtolower($name), '.php')) {
|
||||||
|
$name = 'restore.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = preg_replace('/[^a-zA-Z0-9._-]/', '', $name);
|
||||||
|
|
||||||
|
return $name ?: 'restore.php';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the standalone restore.php script as a separate file.
|
* Generate the standalone restore.php script as a separate file.
|
||||||
*
|
*
|
||||||
@@ -165,7 +176,38 @@ SCANNER;
|
|||||||
$php
|
$php
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Modify the pre-checks to use getSelectedBackupFile() */
|
/* Replace the backup archive check with one that scans for ZIPs
|
||||||
|
(must run BEFORE the blanket file_exists replacement below) */
|
||||||
|
$php = str_replace(
|
||||||
|
<<<'ORIG'
|
||||||
|
$checks[] = [
|
||||||
|
'label' => 'Backup Archive',
|
||||||
|
'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found',
|
||||||
|
'ok' => file_exists(BACKUP_FILE),
|
||||||
|
'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||||
|
];
|
||||||
|
ORIG,
|
||||||
|
<<<'REPL'
|
||||||
|
$availableBackups = scanForBackups();
|
||||||
|
$backupCount = count($availableBackups);
|
||||||
|
$selectedFile = getSelectedBackupFile();
|
||||||
|
if ($selectedFile && file_exists($selectedFile)) {
|
||||||
|
$archiveValue = basename($selectedFile) . ' (' . number_format(filesize($selectedFile) / 1048576, 2) . ' MB)';
|
||||||
|
} elseif ($backupCount > 0) {
|
||||||
|
$archiveValue = $backupCount . ' ZIP file(s) found';
|
||||||
|
} else {
|
||||||
|
$archiveValue = 'No ZIP files found';
|
||||||
|
}
|
||||||
|
$checks[] = [
|
||||||
|
'label' => 'Backup Archive',
|
||||||
|
'value' => $archiveValue,
|
||||||
|
'ok' => $backupCount > 0,
|
||||||
|
'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||||
|
];
|
||||||
|
REPL
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Modify remaining pre-checks to use getSelectedBackupFile() */
|
||||||
$php = str_replace(
|
$php = str_replace(
|
||||||
"file_exists(BACKUP_FILE)",
|
"file_exists(BACKUP_FILE)",
|
||||||
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||||
@@ -174,30 +216,28 @@ SCANNER;
|
|||||||
|
|
||||||
$html = self::generateFrontend();
|
$html = self::generateFrontend();
|
||||||
|
|
||||||
/* Add backup file selector to the frontend before the extract step */
|
/* Inject backup file selector into the extract step (panel2) */
|
||||||
$selectorHtml = <<<'SELECTOR'
|
$selectorHtml = <<<'SELECTOR'
|
||||||
<!-- Backup File Selector (standalone mode) -->
|
<div id="mr-backup-selector" class="mb-3">
|
||||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
<label class="mr-field-label" style="font-weight:600;margin-bottom:8px;display:block;">Backup Archive</label>
|
||||||
<h2 class="mr-step-title">Select Backup File</h2>
|
|
||||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
|
||||||
<div id="mr-backup-list"></div>
|
<div id="mr-backup-list"></div>
|
||||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||||
var list = document.getElementById('mr-backup-list');
|
var list = document.getElementById('mr-backup-list');
|
||||||
var hiddenInput = document.getElementById('mr-backup-file');
|
var hiddenInput = document.getElementById('mr-backup-file');
|
||||||
|
|
||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
var alert = document.createElement('div');
|
var alert = document.createElement('div');
|
||||||
alert.className = 'mr-alert mr-alert-danger';
|
alert.style.cssText = 'padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626;';
|
||||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||||
list.appendChild(alert);
|
list.appendChild(alert);
|
||||||
} else if (backups.length === 1) {
|
} else if (backups.length === 1) {
|
||||||
hiddenInput.value = backups[0].name;
|
hiddenInput.value = backups[0].name;
|
||||||
var found = document.createElement('div');
|
var found = document.createElement('div');
|
||||||
found.className = 'mr-alert mr-alert-success';
|
found.style.cssText = 'padding:12px;background:#dcfce7;border:1px solid #bbf7d0;border-radius:6px;color:#16a34a;';
|
||||||
var strong = document.createElement('strong');
|
var strong = document.createElement('strong');
|
||||||
strong.textContent = backups[0].name;
|
strong.textContent = backups[0].name;
|
||||||
found.appendChild(document.createTextNode('Found: '));
|
found.appendChild(document.createTextNode('Found: '));
|
||||||
@@ -205,34 +245,54 @@ SCANNER;
|
|||||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||||
list.appendChild(found);
|
list.appendChild(found);
|
||||||
} else {
|
} else {
|
||||||
var group = document.createElement('div');
|
var hint = document.createElement('div');
|
||||||
group.className = 'mr-field-group';
|
hint.style.cssText = 'padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1d4ed8;margin-bottom:8px;font-size:0.9em;';
|
||||||
backups.forEach(function(b) {
|
hint.textContent = 'Multiple backup archives found \u2014 select which one to restore:';
|
||||||
|
list.appendChild(hint);
|
||||||
|
backups.forEach(function(b, i) {
|
||||||
var label = document.createElement('label');
|
var label = document.createElement('label');
|
||||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:background 0.15s;';
|
||||||
|
label.onmouseover = function() { this.style.background = '#f8fafc'; };
|
||||||
|
label.onmouseout = function() { this.style.background = ''; };
|
||||||
var radio = document.createElement('input');
|
var radio = document.createElement('input');
|
||||||
radio.type = 'radio';
|
radio.type = 'radio';
|
||||||
radio.name = 'backup_choice';
|
radio.name = 'backup_choice';
|
||||||
radio.value = b.name;
|
radio.value = b.name;
|
||||||
radio.style.marginRight = '8px';
|
radio.style.marginRight = '10px';
|
||||||
|
if (i === 0) { radio.checked = true; hiddenInput.value = b.name; }
|
||||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||||
label.appendChild(radio);
|
label.appendChild(radio);
|
||||||
|
var info = document.createElement('div');
|
||||||
var nameStrong = document.createElement('strong');
|
var nameStrong = document.createElement('strong');
|
||||||
nameStrong.textContent = b.name;
|
nameStrong.textContent = b.name;
|
||||||
label.appendChild(nameStrong);
|
info.appendChild(nameStrong);
|
||||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
var meta = document.createElement('div');
|
||||||
group.appendChild(label);
|
meta.style.cssText = 'font-size:0.85em;color:#64748b;margin-top:2px;';
|
||||||
|
meta.textContent = (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date;
|
||||||
|
info.appendChild(meta);
|
||||||
|
label.appendChild(info);
|
||||||
|
list.appendChild(label);
|
||||||
});
|
});
|
||||||
list.appendChild(group);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
SELECTOR;
|
SELECTOR;
|
||||||
|
|
||||||
/* Insert the selector before the extract step in the HTML */
|
/* Insert the selector into the extract panel */
|
||||||
$html = str_replace(
|
$html = str_replace(
|
||||||
'<!-- Step: Extract -->',
|
'<p class="mr-desc">Extract site-backup.zip into the current directory.</p>',
|
||||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
'<p class="mr-desc">Select a backup archive and extract it into the current directory.</p>' . "\n" . $selectorHtml,
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Pass selected backup file to the extract action */
|
||||||
|
$html = str_replace(
|
||||||
|
"const r = await post('extract', pw ? { archive_password: pw } : {});",
|
||||||
|
"var extraParams = {};\n" .
|
||||||
|
" if (pw) extraParams.archive_password = pw;\n" .
|
||||||
|
" var sel = document.getElementById('mr-backup-file');\n" .
|
||||||
|
" if (sel && sel.value) extraParams.backup_file = sel.value;\n" .
|
||||||
|
" const r = await post('extract', extraParams);",
|
||||||
$html
|
$html
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -286,6 +346,9 @@ define('MOKOJOOMBACKUP_RESTORE', 1);
|
|||||||
define('RESTORE_DIR', __DIR__);
|
define('RESTORE_DIR', __DIR__);
|
||||||
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
|
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();
|
session_start();
|
||||||
|
|
||||||
if (empty($_SESSION['restore_token'])) {
|
if (empty($_SESSION['restore_token'])) {
|
||||||
@@ -298,25 +361,37 @@ $token = $_SESSION['restore_token'];
|
|||||||
// Write a security file to the web root with a random code.
|
// 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
|
// 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.
|
// 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'] ?? '';
|
$securityCode = $_SESSION['security_code'] ?? '';
|
||||||
|
|
||||||
if (empty($securityCode)) {
|
if (empty($securityCode)) {
|
||||||
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||||
$_SESSION['security_code'] = $securityCode;
|
$_SESSION['security_code'] = $securityCode;
|
||||||
$_SESSION['security_verified'] = false;
|
$_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"
|
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
|
||||||
. "MokoRestore Security Verification\n"
|
. "MokoRestore Security Verification\n"
|
||||||
. "==================================\n"
|
. "==================================\n"
|
||||||
. "Code: " . $securityCode . "\n"
|
. "Code: " . $securityCode . "\n"
|
||||||
. "Enter this code in the MokoRestore browser interface to proceed.\n"
|
. "Enter this code in the MokoRestore browser interface to proceed.\n"
|
||||||
. "This file will be deleted automatically after verification.\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;
|
$_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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,15 +402,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
|||||||
|
|
||||||
if ($inputCode === $securityCode) {
|
if ($inputCode === $securityCode) {
|
||||||
$_SESSION['security_verified'] = true;
|
$_SESSION['security_verified'] = true;
|
||||||
|
error_log('MokoRestore: Security code VERIFIED');
|
||||||
|
|
||||||
// Delete the security file
|
|
||||||
if (is_file($securityFile)) {
|
if (is_file($securityFile)) {
|
||||||
@unlink($securityFile);
|
@unlink($securityFile);
|
||||||
|
error_log('MokoRestore: Security file deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'message' => 'Security verified']);
|
echo json_encode(['success' => true, 'message' => 'Security verified']);
|
||||||
} else {
|
} 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;
|
exit;
|
||||||
@@ -354,7 +431,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$securityVerified) {
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,9 +441,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|||||||
@ignore_user_abort(true);
|
@ignore_user_abort(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
error_log('MokoRestore: Action dispatched — ' . $_POST['action']);
|
||||||
$result = handleAction($_POST['action'], $_POST);
|
$result = handleAction($_POST['action'], $_POST);
|
||||||
|
error_log('MokoRestore: Action ' . $_POST['action'] . ' completed — ' . ($result['success'] ? 'OK' : 'FAIL: ' . ($result['message'] ?? '')));
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
error_log('MokoRestore: Action ' . $_POST['action'] . ' EXCEPTION — ' . $e->getMessage());
|
||||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +515,7 @@ function actionPreflight(): array
|
|||||||
'label' => 'Backup Archive',
|
'label' => 'Backup Archive',
|
||||||
'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found',
|
'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found',
|
||||||
'ok' => file_exists(BACKUP_FILE),
|
'ok' => file_exists(BACKUP_FILE),
|
||||||
'hint' => 'site-backup.zip must be in the same directory as restore.php',
|
'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||||
];
|
];
|
||||||
|
|
||||||
$checks[] = [
|
$checks[] = [
|
||||||
@@ -462,23 +542,43 @@ function actionPreflight(): array
|
|||||||
'hint' => 'Informational',
|
'hint' => 'Informational',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$joomlaExists = file_exists(RESTORE_DIR . '/configuration.php')
|
||||||
|
|| file_exists(RESTORE_DIR . '/libraries/src/Version.php');
|
||||||
|
$checks[] = [
|
||||||
|
'label' => 'Existing Installation',
|
||||||
|
'value' => $joomlaExists ? 'Joomla detected' : 'Clean directory',
|
||||||
|
'ok' => true,
|
||||||
|
'warn' => $joomlaExists,
|
||||||
|
'hint' => $joomlaExists
|
||||||
|
? 'WARNING: A Joomla installation already exists in this directory. Restoring will overwrite it.'
|
||||||
|
: 'No existing installation found — safe to proceed',
|
||||||
|
];
|
||||||
|
|
||||||
$allOk = true;
|
$allOk = true;
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
foreach ($checks as $c) {
|
foreach ($checks as $c) {
|
||||||
if (!$c['ok']) {
|
if (!$c['ok']) {
|
||||||
$allOk = false;
|
$allOk = false;
|
||||||
}
|
}
|
||||||
|
if (!empty($c['warn'])) {
|
||||||
|
$warnings[] = $c['hint'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => $allOk, 'checks' => $checks];
|
return ['success' => $allOk, 'checks' => $checks, 'warnings' => $warnings];
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionExtract(array $data): 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)) {
|
if (!file_exists(BACKUP_FILE)) {
|
||||||
throw new RuntimeException('Backup file not found: site-backup.zip');
|
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();
|
$zip = new ZipArchive();
|
||||||
|
|
||||||
if ($zip->open(BACKUP_FILE) !== true) {
|
if ($zip->open(BACKUP_FILE) !== true) {
|
||||||
@@ -515,6 +615,8 @@ function actionExtract(array $data): array
|
|||||||
$count = $zip->numFiles;
|
$count = $zip->numFiles;
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
|
error_log('MokoRestore: Extract — ' . $count . ' files extracted to ' . RESTORE_DIR);
|
||||||
|
|
||||||
// Pre-fill from configuration.php.bak (sanitized backup) or
|
// Pre-fill from configuration.php.bak (sanitized backup) or
|
||||||
// configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values.
|
// configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values.
|
||||||
$existingConfig = [];
|
$existingConfig = [];
|
||||||
@@ -643,6 +745,8 @@ function actionDatabase(array $data): array
|
|||||||
$user = $data['db_user'] ?? '';
|
$user = $data['db_user'] ?? '';
|
||||||
$pass = $data['db_pass'] ?? '';
|
$pass = $data['db_pass'] ?? '';
|
||||||
|
|
||||||
|
error_log('MokoRestore: Database import — host=' . $host . ', db=' . $name . ', user=' . $user);
|
||||||
|
|
||||||
if (empty($name) || empty($user)) {
|
if (empty($name) || empty($user)) {
|
||||||
throw new RuntimeException('Database name and user are required');
|
throw new RuntimeException('Database name and user are required');
|
||||||
}
|
}
|
||||||
@@ -650,9 +754,12 @@ function actionDatabase(array $data): array
|
|||||||
$sqlFile = RESTORE_DIR . '/database.sql';
|
$sqlFile = RESTORE_DIR . '/database.sql';
|
||||||
|
|
||||||
if (!is_file($sqlFile)) {
|
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];
|
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(
|
$pdo = new PDO(
|
||||||
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||||||
$user,
|
$user,
|
||||||
@@ -759,6 +866,14 @@ function actionDatabase(array $data): array
|
|||||||
$msg .= " ({$errors} warnings)";
|
$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 [
|
return [
|
||||||
'success' => ($statements > 0 || $errors === 0),
|
'success' => ($statements > 0 || $errors === 0),
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
@@ -771,6 +886,7 @@ function actionDatabase(array $data): array
|
|||||||
|
|
||||||
function actionConfig(array $data): array
|
function actionConfig(array $data): array
|
||||||
{
|
{
|
||||||
|
error_log('MokoRestore: Config rebuild started');
|
||||||
$host = $data['db_host'] ?? 'localhost';
|
$host = $data['db_host'] ?? 'localhost';
|
||||||
$dbName = $data['db_name'] ?? '';
|
$dbName = $data['db_name'] ?? '';
|
||||||
$dbUser = $data['db_user'] ?? '';
|
$dbUser = $data['db_user'] ?? '';
|
||||||
@@ -791,6 +907,7 @@ function actionConfig(array $data): array
|
|||||||
// debug, cache, SEF, editor, etc.). Fall back to existing config
|
// debug, cache, SEF, editor, etc.). Fall back to existing config
|
||||||
// for legacy/unsanitized backups, or build from scratch if neither exists.
|
// for legacy/unsanitized backups, or build from scratch if neither exists.
|
||||||
$basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null);
|
$basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null);
|
||||||
|
error_log('MokoRestore: Config — base template: ' . ($basePath ?? 'none (building from scratch)'));
|
||||||
|
|
||||||
if ($basePath !== null) {
|
if ($basePath !== null) {
|
||||||
$config = file_get_contents($basePath);
|
$config = file_get_contents($basePath);
|
||||||
@@ -843,9 +960,12 @@ function actionConfig(array $data): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file_put_contents($configPath, $config) === false) {
|
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'];
|
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
|
// Remove .bak after successful rebuild
|
||||||
if (is_file($bakPath)) {
|
if (is_file($bakPath)) {
|
||||||
@unlink($bakPath);
|
@unlink($bakPath);
|
||||||
@@ -1099,6 +1219,8 @@ function actionResetAdmin(array $data): array
|
|||||||
$userId = (int) ($data['admin_id'] ?? 0);
|
$userId = (int) ($data['admin_id'] ?? 0);
|
||||||
$password = $data['new_password'] ?? '';
|
$password = $data['new_password'] ?? '';
|
||||||
|
|
||||||
|
error_log('MokoRestore: Admin password reset — user_id=' . $userId);
|
||||||
|
|
||||||
if ($userId < 1 || strlen($password) < 8) {
|
if ($userId < 1 || strlen($password) < 8) {
|
||||||
throw new RuntimeException('Select an admin and enter a password (8+ characters)');
|
throw new RuntimeException('Select an admin and enter a password (8+ characters)');
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1234,7 @@ function actionResetAdmin(array $data): array
|
|||||||
throw new RuntimeException('User not found or password unchanged');
|
throw new RuntimeException('User not found or password unchanged');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_log('MokoRestore: Admin password reset — success');
|
||||||
return ['success' => true, 'message' => 'Admin password updated successfully'];
|
return ['success' => true, 'message' => 'Admin password updated successfully'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,6 +1244,7 @@ function actionPostRestore(array $data): array
|
|||||||
$prefix = getValidatedPrefix($data);
|
$prefix = getValidatedPrefix($data);
|
||||||
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
||||||
$results = [];
|
$results = [];
|
||||||
|
error_log('MokoRestore: Post-restore — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
try {
|
try {
|
||||||
@@ -1243,6 +1367,7 @@ function actionProvision(array $data): array
|
|||||||
$prefix = getValidatedPrefix($data);
|
$prefix = getValidatedPrefix($data);
|
||||||
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
||||||
$results = [];
|
$results = [];
|
||||||
|
error_log('MokoRestore: Provisioning — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
try {
|
try {
|
||||||
@@ -1319,15 +1444,23 @@ function actionProvision(array $data): array
|
|||||||
|
|
||||||
function actionCleanup(): array
|
function actionCleanup(): array
|
||||||
{
|
{
|
||||||
|
error_log('MokoRestore: Cleanup started');
|
||||||
$removed = [];
|
$removed = [];
|
||||||
|
|
||||||
foreach (['database.sql', 'site-backup.zip'] as $file) {
|
foreach (['database.sql', 'site-backup.zip', 'mokorestore-security.php'] as $file) {
|
||||||
$path = RESTORE_DIR . '/' . $file;
|
$path = RESTORE_DIR . '/' . $file;
|
||||||
|
|
||||||
if (is_file($path) && @unlink($path)) {
|
if (is_file($path)) {
|
||||||
|
if (@unlink($path)) {
|
||||||
$removed[] = $file;
|
$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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -1425,6 +1558,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
.mr-checks li:last-child{border-bottom:none}
|
.mr-checks li:last-child{border-bottom:none}
|
||||||
.mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0}
|
.mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0}
|
||||||
.mr-check-ok{background:#dcfce7;color:#16a34a}
|
.mr-check-ok{background:#dcfce7;color:#16a34a}
|
||||||
|
.mr-check-warn{background:#fef9c3;color:#a16207}
|
||||||
.mr-check-fail{background:#fef2f2;color:#dc2626}
|
.mr-check-fail{background:#fef2f2;color:#dc2626}
|
||||||
.mr-check-info{background:#e0f2fe;color:#0284c7}
|
.mr-check-info{background:#e0f2fe;color:#0284c7}
|
||||||
.mr-check-label{flex:1;font-weight:500}
|
.mr-check-label{flex:1;font-weight:500}
|
||||||
@@ -1474,7 +1608,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
|
|
||||||
<div class="mr-container">
|
<div class="mr-container">
|
||||||
<div class="mr-alert mr-alert-danger">
|
<div class="mr-alert mr-alert-danger">
|
||||||
<strong>Security:</strong> Delete restore.php immediately after installation is complete.
|
<strong>Security:</strong> Delete <code><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></code> immediately after installation is complete.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step Progress -->
|
<!-- Step Progress -->
|
||||||
@@ -1493,14 +1627,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
<!-- Step 0: Security Verification -->
|
<!-- Step 0: Security Verification -->
|
||||||
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
|
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
|
||||||
<h2>Security Verification</h2>
|
<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="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">
|
<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
|
<span style="font-size:1.1rem">🔒</span> How to find the code
|
||||||
</div>
|
</div>
|
||||||
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
|
<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>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>
|
<li>Copy the 8-character code and enter it below</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -1722,7 +1856,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
<strong>Success!</strong> The site restoration is complete.
|
<strong>Success!</strong> The site restoration is complete.
|
||||||
</div>
|
</div>
|
||||||
<div class="mr-alert mr-alert-danger">
|
<div class="mr-alert mr-alert-danger">
|
||||||
<strong>Important:</strong> Delete <code>restore.php</code> and <code>site-backup.zip</code> from your server immediately for security.
|
<strong>Important:</strong> Delete <code><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></code> and <code>site-backup.zip</code> from your server immediately for security.
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:1rem">
|
<div style="margin-top:1rem">
|
||||||
<button class="mr-btn mr-btn-danger" onclick="runCleanup()">Remove Restore Files</button>
|
<button class="mr-btn mr-btn-danger" onclick="runCleanup()">Remove Restore Files</button>
|
||||||
@@ -1746,6 +1880,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TOKEN = <?php echo json_encode($token); ?>;
|
const TOKEN = <?php echo json_encode($token); ?>;
|
||||||
|
const SCRIPT_URL = <?php echo json_encode(basename($_SERVER['SCRIPT_NAME'])); ?>;
|
||||||
let currentStep = 1;
|
let currentStep = 1;
|
||||||
let dbConfig = {};
|
let dbConfig = {};
|
||||||
|
|
||||||
@@ -1769,8 +1904,23 @@ async function post(action, extra) {
|
|||||||
form.append(k, v);
|
form.append(k, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await fetch('restore.php', { method: 'POST', body: form });
|
var res;
|
||||||
return res.json();
|
try {
|
||||||
|
res = await fetch(SCRIPT_URL, { method: 'POST', body: form });
|
||||||
|
} catch (e) {
|
||||||
|
log('Network error: ' + e.message);
|
||||||
|
return { success: false, message: 'Network error: ' + e.message, checks: [] };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
log('Server error: HTTP ' + res.status);
|
||||||
|
return { success: false, message: 'Server error (HTTP ' + res.status + ')', checks: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
log('Invalid response from server (not JSON)');
|
||||||
|
return { success: false, message: 'Invalid server response — check PHP error log', checks: [] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goStep(n) {
|
function goStep(n) {
|
||||||
@@ -1845,15 +1995,27 @@ async function runPreflight() {
|
|||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
log('Running pre-flight checks...');
|
log('Running pre-flight checks...');
|
||||||
|
|
||||||
|
try {
|
||||||
const r = await post('preflight');
|
const r = await post('preflight');
|
||||||
|
|
||||||
|
if (!r.success && !r.checks.length) {
|
||||||
|
log('Pre-flight error: ' + (r.message || 'Unknown error'));
|
||||||
|
setBtnLoading(btn, false);
|
||||||
|
btn.textContent = 'Re-check';
|
||||||
|
setStatus('checkList', r.message || 'Pre-flight check failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const list = document.getElementById('checkList');
|
const list = document.getElementById('checkList');
|
||||||
while (list.firstChild) list.removeChild(list.firstChild);
|
while (list.firstChild) list.removeChild(list.firstChild);
|
||||||
|
|
||||||
r.checks.forEach(function(c) {
|
r.checks.forEach(function(c) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
const icon = document.createElement('span');
|
const icon = document.createElement('span');
|
||||||
icon.className = 'mr-check-icon ' + (c.ok ? 'mr-check-ok' : 'mr-check-fail');
|
var iconClass = c.ok ? 'mr-check-ok' : 'mr-check-fail';
|
||||||
icon.textContent = c.ok ? '\u2713' : '\u2717';
|
if (c.warn) iconClass = 'mr-check-warn';
|
||||||
|
icon.className = 'mr-check-icon ' + iconClass;
|
||||||
|
icon.textContent = c.warn ? '\u26a0' : (c.ok ? '\u2713' : '\u2717');
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'mr-check-label';
|
label.className = 'mr-check-label';
|
||||||
@@ -1866,9 +2028,16 @@ async function runPreflight() {
|
|||||||
li.appendChild(icon);
|
li.appendChild(icon);
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
li.appendChild(val);
|
li.appendChild(val);
|
||||||
|
if (c.warn && c.hint) {
|
||||||
|
var hint = document.createElement('div');
|
||||||
|
hint.style.cssText = 'font-size:0.85em;color:#a16207;margin-top:4px;padding:4px 8px;background:#fef9c3;border-radius:4px;';
|
||||||
|
hint.textContent = c.hint;
|
||||||
|
li.appendChild(hint);
|
||||||
|
}
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
|
|
||||||
log(' ' + (c.ok ? 'OK' : 'FAIL') + ': ' + c.label + ' = ' + c.value);
|
var logPrefix = c.warn ? 'WARN' : (c.ok ? 'OK' : 'FAIL');
|
||||||
|
log(' ' + logPrefix + ': ' + c.label + ' = ' + c.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
setBtnLoading(btn, false);
|
setBtnLoading(btn, false);
|
||||||
@@ -1882,6 +2051,11 @@ async function runPreflight() {
|
|||||||
btn.textContent = 'Re-check';
|
btn.textContent = 'Re-check';
|
||||||
log('Some checks failed');
|
log('Some checks failed');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Pre-flight error: ' + e.message);
|
||||||
|
setBtnLoading(btn, false);
|
||||||
|
btn.textContent = 'Re-check';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2
|
// Step 2
|
||||||
|
|||||||
@@ -51,7 +51,32 @@ class PlaceholderResolver
|
|||||||
public function __construct(object $profile)
|
public function __construct(object $profile)
|
||||||
{
|
{
|
||||||
$now = new \DateTimeImmutable('now');
|
$now = new \DateTimeImmutable('now');
|
||||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
|
||||||
|
/* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */
|
||||||
|
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawHost) || $rawHost === 'localhost') {
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$liveSite = $app->get('live_site', '');
|
||||||
|
|
||||||
|
if (!empty($liveSite)) {
|
||||||
|
$parsed = parse_url($liveSite, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!empty($parsed)) {
|
||||||
|
$rawHost = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rawHost)) {
|
||||||
|
$rawHost = php_uname('n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
|
||||||
|
|
||||||
$siteName = '';
|
$siteName = '';
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class PreflightCheck
|
|||||||
$this->checkDiskSpace($profile, $db);
|
$this->checkDiskSpace($profile, $db);
|
||||||
$this->checkRunningBackup($profile, $db);
|
$this->checkRunningBackup($profile, $db);
|
||||||
$this->checkExcludedTables($profile, $db);
|
$this->checkExcludedTables($profile, $db);
|
||||||
$this->checkRemoteCredentials($profile);
|
$this->checkRemoteCredentials($profile, $db);
|
||||||
|
|
||||||
return $this->result();
|
return $this->result();
|
||||||
}
|
}
|
||||||
@@ -102,12 +102,8 @@ class PreflightCheck
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// curl is only needed for remote upload and ntfy notifications
|
if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) {
|
||||||
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|
$this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work';
|
||||||
|| !empty($profile->ntfy_topic);
|
|
||||||
|
|
||||||
if ($needsCurl && !extension_loaded('curl')) {
|
|
||||||
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +161,7 @@ class PreflightCheck
|
|||||||
->select($db->quoteName('total_size'))
|
->select($db->quoteName('total_size'))
|
||||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
->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')
|
->where($db->quoteName('total_size') . ' > 0')
|
||||||
->order($db->quoteName('backupstart') . ' DESC');
|
->order($db->quoteName('backupstart') . ' DESC');
|
||||||
$db->setQuery($query, 0, 1);
|
$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.
|
* 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
|
private function checkRunningBackup(object $profile, object $db): void
|
||||||
{
|
{
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select($db->quoteName(['id', 'backupstart', 'absolute_path']))
|
||||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||||
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
|
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
|
||||||
$db->setQuery($query);
|
$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
|
$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,67 +276,78 @@ 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).
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ($remote) {
|
foreach ($remotes as $remote) {
|
||||||
|
$params = json_decode($remote->params, true) ?: [];
|
||||||
|
$label = $remote->title ?: ('Remote #' . $remote->id);
|
||||||
|
|
||||||
|
switch ($remote->type) {
|
||||||
case 'ftp':
|
case 'ftp':
|
||||||
if (empty($profile->ftp_host)) {
|
if (empty($params['host'])) {
|
||||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
$this->warnings[] = $label . ': FTP host is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($profile->ftp_username)) {
|
if (empty($params['username'])) {
|
||||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
$this->warnings[] = $label . ': FTP username is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 's3':
|
case 's3':
|
||||||
if (empty($profile->s3_bucket)) {
|
if (empty($params['bucket'])) {
|
||||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
$this->warnings[] = $label . ': S3 bucket is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
if (empty($params['access_key']) || empty($params['secret_key'])) {
|
||||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
$this->warnings[] = $label . ': S3 credentials are not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'sftp':
|
case 'sftp':
|
||||||
if (empty($profile->sftp_host)) {
|
if (empty($params['host'])) {
|
||||||
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
$this->warnings[] = $label . ': SFTP host is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($profile->sftp_username)) {
|
if (empty($params['username'])) {
|
||||||
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
$this->warnings[] = $label . ': SFTP username is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
if (empty($params['key_data']) && empty($params['password'])) {
|
||||||
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
$this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'google_drive':
|
case 'google_drive':
|
||||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
if (empty($params['client_id']) || empty($params['client_secret'])) {
|
||||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
$this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($profile->gdrive_refresh_token)) {
|
if (empty($params['refresh_token'])) {
|
||||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
$this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the result array.
|
* Build the result array.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RestoreEngine
|
|||||||
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
|
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 . ')'];
|
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
|
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) {
|
if ($this->port !== 22) {
|
||||||
$parts[] = '-P';
|
$parts[] = '-P';
|
||||||
@@ -235,7 +235,7 @@ class SftpUploader implements RemoteUploaderInterface
|
|||||||
*/
|
*/
|
||||||
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
|
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) {
|
if ($this->port !== 22) {
|
||||||
$parts[] = '-p';
|
$parts[] = '-p';
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ class SteppedBackupEngine
|
|||||||
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
||||||
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||||
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||||
$session->remoteStorage = $profile->remote_storage ?? 'none';
|
$session->includeMokoRestore = $profile->include_mokorestore ?? '0';
|
||||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$session->restoreScriptName = $profile->restore_script_name ?? 'restore.php';
|
||||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||||
|
|
||||||
// Load multi-remote destinations from the remotes table
|
// Load multi-remote destinations from the remotes table
|
||||||
@@ -152,15 +152,8 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$totalSteps += 1; // finalize step
|
$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);
|
$remoteCount = count($session->remoteDestinations);
|
||||||
|
|
||||||
if ($remoteCount > 0) {
|
|
||||||
$totalSteps += $remoteCount;
|
$totalSteps += $remoteCount;
|
||||||
} elseif ($session->remoteStorage !== 'none') {
|
|
||||||
$totalSteps += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$session->totalSteps = $totalSteps;
|
$session->totalSteps = $totalSteps;
|
||||||
$session->currentStep = 1;
|
$session->currentStep = 1;
|
||||||
@@ -377,15 +370,30 @@ class SteppedBackupEngine
|
|||||||
$this->verifyArchive($session->archivePath, $session->backupType);
|
$this->verifyArchive($session->archivePath, $session->backupType);
|
||||||
$session->log('Archive integrity verified');
|
$session->log('Archive integrity verified');
|
||||||
|
|
||||||
// MokoRestore wrapper
|
// MokoRestore
|
||||||
if ($session->includeMokoRestore) {
|
$mokoRestoreMode = $session->includeMokoRestore ?? '0';
|
||||||
|
$restoreScriptName = $session->restoreScriptName ?? 'restore.php';
|
||||||
|
|
||||||
|
if ($mokoRestoreMode === '1') {
|
||||||
$session->log('Wrapping with MokoRestore script...');
|
$session->log('Wrapping with MokoRestore script...');
|
||||||
$mokoRestorePath = $session->archivePath . '.mokorestore.zip';
|
$mokoRestorePath = $session->archivePath . '.mokorestore.zip';
|
||||||
MokoRestore::wrap($session->archivePath, $mokoRestorePath);
|
MokoRestore::wrap($session->archivePath, $mokoRestorePath, $restoreScriptName);
|
||||||
@unlink($session->archivePath);
|
@unlink($session->archivePath);
|
||||||
rename($mokoRestorePath, $session->archivePath);
|
rename($mokoRestorePath, $session->archivePath);
|
||||||
$totalSize = filesize($session->archivePath);
|
$totalSize = filesize($session->archivePath);
|
||||||
$session->log('MokoRestore archive created');
|
$session->log('MokoRestore archive created');
|
||||||
|
} elseif ($mokoRestoreMode === 'standalone') {
|
||||||
|
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||||
|
$restoreDir = dirname($session->archivePath);
|
||||||
|
$session->restoreScriptPath = $restoreDir . '/' . $restoreScriptName;
|
||||||
|
|
||||||
|
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
|
// Update record
|
||||||
@@ -405,11 +413,7 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$session->currentStep++;
|
$session->currentStep++;
|
||||||
|
|
||||||
// Determine next phase: multi-remote, legacy single-remote, or complete
|
if (!empty($session->remoteDestinations)) {
|
||||||
$hasMultiRemote = !empty($session->remoteDestinations);
|
|
||||||
$hasLegacyRemote = $session->remoteStorage !== 'none';
|
|
||||||
|
|
||||||
if ($hasMultiRemote || $hasLegacyRemote) {
|
|
||||||
$session->phase = 'upload';
|
$session->phase = 'upload';
|
||||||
} else {
|
} else {
|
||||||
$session->phase = 'complete';
|
$session->phase = 'complete';
|
||||||
@@ -424,11 +428,7 @@ class SteppedBackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload phase: send archive to remote storage.
|
* Upload phase: send archive to one remote destination per call.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
private function stepUpload(SteppedSession $session): void
|
private function stepUpload(SteppedSession $session): void
|
||||||
{
|
{
|
||||||
@@ -436,12 +436,9 @@ class SteppedBackupEngine
|
|||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
if (!empty($session->remoteDestinations)) {
|
|
||||||
// ── Multi-remote path ──────────────────────────────────
|
|
||||||
$index = $session->remoteIndex;
|
$index = $session->remoteIndex;
|
||||||
|
|
||||||
if ($index >= count($session->remoteDestinations)) {
|
if ($index >= count($session->remoteDestinations)) {
|
||||||
// All remotes processed — move to complete
|
|
||||||
$session->phase = 'complete';
|
$session->phase = 'complete';
|
||||||
$session->statusMessage = 'All remote uploads finished';
|
$session->statusMessage = 'All remote uploads finished';
|
||||||
$this->completeRecord($session);
|
$this->completeRecord($session);
|
||||||
@@ -463,6 +460,10 @@ class SteppedBackupEngine
|
|||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
$session->log(' Upload complete: ' . $result['message']);
|
$session->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
|
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||||
|
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
@@ -479,13 +480,11 @@ class SteppedBackupEngine
|
|||||||
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||||
|
|
||||||
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||||
// All remotes done — delete local if configured and no failures
|
|
||||||
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
@unlink($session->archivePath);
|
@unlink($session->archivePath);
|
||||||
$session->log('Local copy removed (remote_keep_local = off)');
|
$session->log('Local copy removed (remote_keep_local = off)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record with remote filename
|
|
||||||
$update = (object) [
|
$update = (object) [
|
||||||
'id' => $session->recordId,
|
'id' => $session->recordId,
|
||||||
'remote_filename' => $remoteFilename,
|
'remote_filename' => $remoteFilename,
|
||||||
@@ -499,63 +498,6 @@ class SteppedBackupEngine
|
|||||||
: 'Backup complete';
|
: 'Backup complete';
|
||||||
$this->completeRecord($session, $uploadFailed);
|
$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 (!$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';
|
|
||||||
$this->completeRecord($session, $uploadFailed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,7 +563,7 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$update = (object) [
|
$update = (object) [
|
||||||
'id' => $session->recordId,
|
'id' => $session->recordId,
|
||||||
'status' => 'complete',
|
'status' => $uploadFailed ? 'warning' : 'complete',
|
||||||
'backupend' => date('Y-m-d H:i:s'),
|
'backupend' => date('Y-m-d H:i:s'),
|
||||||
'total_size' => $totalSize,
|
'total_size' => $totalSize,
|
||||||
'checksum' => $checksum,
|
'checksum' => $checksum,
|
||||||
@@ -660,6 +602,13 @@ class SteppedBackupEngine
|
|||||||
if ($uploadFailed) {
|
if ($uploadFailed) {
|
||||||
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
|
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) {
|
} catch (\Throwable $e) {
|
||||||
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
||||||
@@ -833,7 +782,6 @@ class SteppedBackupEngine
|
|||||||
*/
|
*/
|
||||||
private function loadRemoteDestinations(object $db, int $profileId): array
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
@@ -842,12 +790,7 @@ class SteppedBackupEngine
|
|||||||
->order($db->quoteName('ordering') . ' ASC');
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
|
|
||||||
// Use loadAssocList so the data survives JSON serialization in SteppedSession
|
|
||||||
return $db->loadAssocList() ?: [];
|
return $db->loadAssocList() ?: [];
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -863,7 +806,16 @@ class SteppedBackupEngine
|
|||||||
*/
|
*/
|
||||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
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) {
|
return match ($type) {
|
||||||
'ftp' => new FtpUploader($fake),
|
'ftp' => new FtpUploader($fake),
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class SteppedRestoreEngine
|
|||||||
return ['error' => true, 'message' => 'Backup record not found: ' . $recordId];
|
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 . ')'];
|
return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ class SteppedSession
|
|||||||
public array $excludeDirs = [];
|
public array $excludeDirs = [];
|
||||||
public array $excludeFiles = [];
|
public array $excludeFiles = [];
|
||||||
public array $excludeTables = [];
|
public array $excludeTables = [];
|
||||||
public string $remoteStorage = 'none';
|
public string $includeMokoRestore = '0';
|
||||||
public bool $includeMokoRestore = false;
|
public string $restoreScriptName = 'restore.php';
|
||||||
|
public string $restoreScriptPath = '';
|
||||||
public bool $remoteKeepLocal = true;
|
public bool $remoteKeepLocal = true;
|
||||||
public string $encryptionPassword = '';
|
public string $encryptionPassword = '';
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build placeholder map for JS resolution
|
// Build placeholder map for JS resolution
|
||||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
/* Resolve hostname: prefer HTTP_HOST, then Joomla live_site config, then system hostname */
|
||||||
|
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawHost) || $rawHost === 'localhost') {
|
||||||
|
try {
|
||||||
|
$liveSite = Factory::getApplication()->get('live_site', '');
|
||||||
|
|
||||||
|
if (!empty($liveSite)) {
|
||||||
|
$parsed = parse_url($liveSite, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!empty($parsed)) {
|
||||||
|
$rawHost = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rawHost)) {
|
||||||
|
$rawHost = php_uname('n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
|
||||||
$siteName = '';
|
$siteName = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user