Compare commits
284 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 | |||
| 4bafaa519a | |||
| 3c32bd93e9 | |||
| ef17873448 | |||
| dae30161ae | |||
| 8e70bfb723 | |||
| dcd772018e | |||
| 26d765b74e | |||
| 78b68d2647 | |||
| 50a879155d | |||
| b4fb674566 | |||
| 1b93d2ac21 | |||
| 8e5913d706 | |||
| 1f7def05c1 | |||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,67 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# 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)
|
||||||
|
|
||||||
name: "Universal: Auto Version Bump"
|
name: "Universal: Auto Version Bump"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- rc
|
- rc
|
||||||
- 'feature/**'
|
- 'feature/**'
|
||||||
- 'patch/**'
|
- 'patch/**'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump:
|
bump:
|
||||||
name: Version Bump
|
name: Version Bump
|
||||||
runs-on: release
|
runs-on: release
|
||||||
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:
|
|
||||||
- name: Checkout
|
steps:
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
- name: Checkout
|
||||||
with:
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
with:
|
||||||
fetch-depth: 1
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
fetch-depth: 1
|
||||||
- name: Setup mokocli tools
|
|
||||||
run: |
|
- name: Setup mokocli tools
|
||||||
if ! command -v composer &> /dev/null; then
|
run: |
|
||||||
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
|
if ! command -v composer &> /dev/null; then
|
||||||
fi
|
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
|
||||||
if [ -d "/opt/mokocli/cli" ]; then
|
fi
|
||||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
if [ -d "/opt/mokocli/cli" ]; then
|
||||||
else
|
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
git clone --depth 1 --branch main --quiet \
|
else
|
||||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
git clone --depth 1 --branch main --quiet \
|
||||||
/tmp/mokocli
|
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/MokoCLI.git" \
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
/tmp/mokocli
|
||||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
fi
|
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
- name: Bump version
|
|
||||||
run: |
|
- name: Bump version
|
||||||
php ${MOKO_CLI}/version_auto_bump.php \
|
run: |
|
||||||
--path . --branch "${GITHUB_REF_NAME}" \
|
php ${MOKO_CLI}/version_auto_bump.php \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--path . --branch "${GITHUB_REF_NAME}" \
|
||||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|||||||
@@ -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: >-
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
(
|
||||||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
)
|
||||||
|
|
||||||
steps:
|
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: >-
|
||||||
github.event.pull_request.merged == true ||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
(
|
||||||
|
github.event.pull_request.merged == true ||
|
||||||
|
(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,10 +380,13 @@ 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.
|
||||||
open('CHANGELOG.md', 'w').write(content)
|
if marker not in content:
|
||||||
|
new = f'## [Unreleased]\n\n{marker} --- {date}'
|
||||||
|
content = content.replace('## [Unreleased]', new, 1)
|
||||||
|
open('CHANGELOG.md', 'w').write(content)
|
||||||
" "$VERSION" "$DATE"
|
" "$VERSION" "$DATE"
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md
|
||||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||||
@@ -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.00.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
|
||||||
|
|
||||||
|
|||||||
+602
-534
File diff suppressed because it is too large
Load Diff
@@ -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: >-
|
||||||
github.event_name == 'workflow_dispatch' ||
|
!startsWith(github.event.repository.name, 'Template-') &&
|
||||||
github.event_name == 'push'
|
(
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
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"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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: |
|
||||||
|
|||||||
+24
-18
@@ -2,29 +2,35 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.39.01] --- 2026-06-23
|
## [02.57.00] --- 2026-07-05
|
||||||
|
|
||||||
## [01.39.01] --- 2026-06-23
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- MokoRestore: post-restore reset options — passwords, hits, versions, sessions, cache (#131)
|
- `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)
|
||||||
- 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")
|
|
||||||
|
|
||||||
## [01.38.05] --- 2026-06-23
|
## [02.56.11] --- 2026-07-05
|
||||||
|
|
||||||
## [01.38.05] --- 2026-06-23
|
### 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)
|
||||||
|
|
||||||
## [01.38.04] --- 2026-06-23
|
## [02.56.08] --- 2026-07-05
|
||||||
|
|
||||||
## [01.38.04] --- 2026-06-23
|
### 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
@@ -12,5 +12,9 @@
|
|||||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||||
|
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
|
||||||
|
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
|
||||||
|
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
||||||
|
<action name="mokosuitebackup.backup.cancel" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL" />
|
||||||
</section>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
|
|||||||
*/
|
*/
|
||||||
public function displayList(): static
|
public function displayList(): static
|
||||||
{
|
{
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->app->setHeader('status', 403);
|
$this->app->setHeader('status', 403);
|
||||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||||
$this->app->close();
|
$this->app->close();
|
||||||
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
|
|||||||
*/
|
*/
|
||||||
public function download(): static
|
public function download(): static
|
||||||
{
|
{
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->app->setHeader('status', 403);
|
$this->app->setHeader('status', 403);
|
||||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||||
$this->app->close();
|
$this->app->close();
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -39,6 +39,73 @@
|
|||||||
</field>
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="defaults" label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS">
|
||||||
|
<field
|
||||||
|
name="default_archive_format"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC"
|
||||||
|
default="zip"
|
||||||
|
>
|
||||||
|
<option value="zip">ZIP</option>
|
||||||
|
<option value="tar.gz">tar.gz</option>
|
||||||
|
<option value="7z">7z</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_mokorestore"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC"
|
||||||
|
default="0"
|
||||||
|
>
|
||||||
|
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||||
|
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||||
|
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_passwords"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_emails"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_sessions"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="log_retention_days"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC"
|
||||||
|
default="90"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
||||||
<field
|
<field
|
||||||
name="webcron_secret"
|
name="webcron_secret"
|
||||||
@@ -172,6 +239,32 @@
|
|||||||
</field>
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="ntfy" label="COM_MOKOJOOMBACKUP_CONFIG_NTFY">
|
||||||
|
<field
|
||||||
|
name="ntfy_server"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
|
||||||
|
default="https://ntfy.mokoconsulting.tech"
|
||||||
|
filter="url"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="ntfy_topic"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC"
|
||||||
|
default=""
|
||||||
|
filter="string"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="ntfy_token"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC"
|
||||||
|
default=""
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
||||||
description="JCONFIG_PERMISSIONS_DESC">
|
description="JCONFIG_PERMISSIONS_DESC">
|
||||||
<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,18 +206,6 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||||
<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"
|
||||||
@@ -225,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">
|
||||||
@@ -397,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"
|
||||||
@@ -414,6 +421,40 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
|||||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||||
|
|
||||||
|
; Component Options — Defaults
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS="Profile Defaults"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT="Default Archive Format"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC="Archive format used when creating new profiles. Can be overridden per profile."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE="Default MokoRestore Mode"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC="MokoRestore mode for new profiles. None, Wrapped (inside ZIP), or Standalone (separate file)."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW="Default: Sanitize Passwords"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC="Whether new profiles should sanitize user passwords by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL="Default: Sanitize Emails"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC="Whether new profiles should sanitize user emails by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS="Default: Clear Sessions"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC="Whether new profiles should clear session data by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION="Log Retention (days)"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC="Days to keep .log files alongside backup archives. Set to 0 for unlimited."
|
||||||
|
|
||||||
|
; Component Options — ntfy
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY="Push Notifications (ntfy)"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER="Global ntfy Server"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC="Default ntfy server URL. Per-profile settings override this."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC="Global ntfy Topic"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC="Default ntfy topic for backup notifications. Per-profile settings override this."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN="Global ntfy Token"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC="Default access token for private ntfy topics. Per-profile settings override this."
|
||||||
|
|
||||||
|
; ACL — additional actions
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE="Purge Old Backups"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE_DESC="Allows users to bulk-delete backups older than a specific date."
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE="Compare Backups"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side."
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting."
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
|
||||||
|
|
||||||
; Snapshot ACL
|
; Snapshot ACL
|
||||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
||||||
@@ -463,6 +504,21 @@ 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)
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_EDIT="Edit Destination"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_ENABLED="Enabled"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED="No remote destinations configured. Use 'Add Destination' to send backups to SFTP, S3, or Google Drive."
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE="Legacy single-remote fields below are hidden when remote destinations are configured above. Existing legacy settings continue to work as a fallback."
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM="Are you sure you want to delete this remote destination?"
|
||||||
|
|
||||||
; Errors
|
; Errors
|
||||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||||
|
|||||||
@@ -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.39.01</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 '',
|
||||||
@@ -107,17 +81,32 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
|||||||
KEY `idx_created` (`created`)
|
KEY `idx_created` (`created`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||||
|
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||||
|
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_profile` (`profile_id`),
|
||||||
|
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||||
|
) 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)
|
||||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
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,2 +1,4 @@
|
|||||||
|
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,97 @@
|
|||||||
|
-- MokoSuiteBackup 01.41.00 — Multi-remote storage destinations (#97)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||||
|
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||||
|
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_profile` (`profile_id`),
|
||||||
|
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Migrate existing SFTP remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - SFTP'),
|
||||||
|
'sftp',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'host', `sftp_host`,
|
||||||
|
'port', `sftp_port`,
|
||||||
|
'username', `sftp_username`,
|
||||||
|
'auth_type', `sftp_auth_type`,
|
||||||
|
'password', `sftp_password`,
|
||||||
|
'key_data', COALESCE(`sftp_key_data`, ''),
|
||||||
|
'passphrase', `sftp_passphrase`,
|
||||||
|
'path', `sftp_path`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'sftp' AND `sftp_host` != '';
|
||||||
|
|
||||||
|
-- Migrate existing S3 remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - S3'),
|
||||||
|
's3',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'endpoint', `s3_endpoint`,
|
||||||
|
'region', `s3_region`,
|
||||||
|
'access_key', `s3_access_key`,
|
||||||
|
'secret_key', `s3_secret_key`,
|
||||||
|
'bucket', `s3_bucket`,
|
||||||
|
'path', `s3_path`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 's3' AND `s3_bucket` != '';
|
||||||
|
|
||||||
|
-- Migrate existing Google Drive remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - Google Drive'),
|
||||||
|
'google_drive',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'client_id', `gdrive_client_id`,
|
||||||
|
'client_secret', `gdrive_client_secret`,
|
||||||
|
'refresh_token', `gdrive_refresh_token`,
|
||||||
|
'folder_id', `gdrive_folder_id`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'google_drive' AND `gdrive_client_id` != '';
|
||||||
|
|
||||||
|
-- Migrate existing FTP remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - FTP'),
|
||||||
|
'ftp',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'host', `ftp_host`,
|
||||||
|
'port', `ftp_port`,
|
||||||
|
'username', `ftp_username`,
|
||||||
|
'password', `ftp_password`,
|
||||||
|
'path', `ftp_path`,
|
||||||
|
'passive', `ftp_passive`,
|
||||||
|
'ssl', `ftp_ssl`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'ftp' AND `ftp_host` != '';
|
||||||
@@ -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
|
||||||
@@ -348,7 +409,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -384,7 +445,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -416,7 +477,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.browse', 'com_mokosuitebackup')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -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;
|
||||||
@@ -725,7 +786,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
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) {
|
||||||
@@ -776,7 +837,7 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -879,11 +940,15 @@ class AjaxController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Remote Destinations CRUD
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse directories on a remote SFTP server for the path picker.
|
* List remote destinations for a profile.
|
||||||
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
* POST: task=ajax.listRemotes&profile_id=1
|
||||||
*/
|
*/
|
||||||
public function browseSftpDir(): void
|
public function listRemotes(): void
|
||||||
{
|
{
|
||||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
@@ -905,156 +970,301 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Load the profile to get SFTP credentials */
|
|
||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||||
|
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
$profile = $db->loadObject();
|
$rows = $db->loadObjectList();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$profile) {
|
// Decode JSON params and mask secrets
|
||||||
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$config = json_decode($row->params, true) ?: [];
|
||||||
|
|
||||||
|
// Mask sensitive fields so they never leave the server in list views
|
||||||
|
$masked = $this->maskSecrets($config, $row->type);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'profile_id' => (int) $row->profile_id,
|
||||||
|
'title' => $row->title,
|
||||||
|
'type' => $row->type,
|
||||||
|
'enabled' => (int) $row->enabled,
|
||||||
|
'params' => $masked,
|
||||||
|
'ordering' => (int) $row->ordering,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson(['error' => false, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save (create or update) a remote destination.
|
||||||
|
* POST: task=ajax.saveRemote (JSON body or form fields)
|
||||||
|
*/
|
||||||
|
public function saveRemote(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $profile->sftp_host ?? '';
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
$port = (int) ($profile->sftp_port ?? 22);
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($keyData) && empty($password)) {
|
$id = $this->input->getInt('remote_id', 0);
|
||||||
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
$title = trim($this->input->getString('remote_title', ''));
|
||||||
|
$type = $this->input->getCmd('remote_type', 'sftp');
|
||||||
|
$enabled = $this->input->getInt('remote_enabled', 1);
|
||||||
|
$configRaw = $this->input->getString('remote_config', '{}');
|
||||||
|
|
||||||
|
if (!$profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestPath = $this->input->getString('path', '/');
|
if (empty($title)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Title is required']);
|
||||||
/* 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$keyFile = null;
|
$config = json_decode($configRaw, true);
|
||||||
|
|
||||||
|
if (!is_array($config)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid config JSON']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If editing, merge secrets that were masked with __KEEP_EXISTING__
|
||||||
|
if ($id) {
|
||||||
|
$config = $this->mergeExistingSecrets($id, $config, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/* Write temp key if using key auth (same pattern as SftpUploader) */
|
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
|
||||||
if (!empty($keyData)) {
|
|
||||||
$keyContent = base64_decode($keyData, true);
|
|
||||||
|
|
||||||
if ($keyContent === false) {
|
if ($id) {
|
||||||
$keyContent = $keyData;
|
$table->load($id);
|
||||||
}
|
|
||||||
|
|
||||||
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
|
// Verify ownership
|
||||||
|
if ((int) $table->profile_id !== $profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
|
||||||
|
|
||||||
if (file_put_contents($keyFile, $keyContent) === false) {
|
return;
|
||||||
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([
|
$table->profile_id = $profileId;
|
||||||
'error' => false,
|
$table->title = $title;
|
||||||
'current' => $requestPath,
|
$table->type = $type;
|
||||||
'parent' => $parent,
|
$table->enabled = $enabled ? 1 : 0;
|
||||||
'dirs' => $dirs,
|
$table->params = json_encode($config);
|
||||||
]);
|
if (!$table->check() || !$table->store()) {
|
||||||
} catch (\Throwable $e) {
|
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
||||||
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
|
|
||||||
} finally {
|
return;
|
||||||
if ($keyFile !== null && is_file($keyFile)) {
|
}
|
||||||
unlink($keyFile);
|
|
||||||
|
$this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a remote destination.
|
||||||
|
* POST: task=ajax.deleteRemote&remote_id=1&profile_id=1
|
||||||
|
*/
|
||||||
|
public function deleteRemote(): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('remote_id', 0);
|
||||||
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
|
||||||
|
if (!$id || !$profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->delete($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id)
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
|
||||||
|
$this->sendJson(['error' => false, 'message' => 'Deleted']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle enabled/disabled for a remote destination.
|
||||||
|
* POST: task=ajax.toggleRemote&remote_id=1&profile_id=1
|
||||||
|
*/
|
||||||
|
public function toggleRemote(): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('remote_id', 0);
|
||||||
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
|
||||||
|
if (!$id || !$profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Load current state
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('enabled'))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id)
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$current = $db->loadResult();
|
||||||
|
|
||||||
|
if ($current === null) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Remote not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newState = $current ? 0 : 1;
|
||||||
|
|
||||||
|
$update = $db->getQuery(true)
|
||||||
|
->update($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->set($db->quoteName('enabled') . ' = ' . $newState)
|
||||||
|
->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id)
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||||
|
$db->setQuery($update);
|
||||||
|
$db->execute();
|
||||||
|
|
||||||
|
$this->sendJson(['error' => false, 'enabled' => $newState]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive values in a remote config array for display.
|
||||||
|
*/
|
||||||
|
private function maskSecrets(array $config, string $type): array
|
||||||
|
{
|
||||||
|
$secrets = [
|
||||||
|
'ftp' => ['password'],
|
||||||
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||||
|
's3' => ['secret_key'],
|
||||||
|
'google_drive' => ['client_secret', 'refresh_token'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$fields = $secrets[$type] ?? [];
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (!empty($config[$field])) {
|
||||||
|
$config[$field] = '********';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When updating a remote, merge back secrets that were masked in the form.
|
||||||
|
*/
|
||||||
|
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
||||||
|
{
|
||||||
|
$secrets = [
|
||||||
|
'ftp' => ['password'],
|
||||||
|
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||||
|
's3' => ['secret_key'],
|
||||||
|
'google_drive' => ['client_secret', 'refresh_token'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$fields = $secrets[$type] ?? [];
|
||||||
|
$needsMerge = false;
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
||||||
|
$needsMerge = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$needsMerge) {
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing config from DB
|
||||||
|
try {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('params'))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$existing = json_decode($db->loadResult() ?: '{}', true) ?: [];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
||||||
|
$config[$field] = $existing[$field] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class BackupsController extends AdminController
|
|||||||
{
|
{
|
||||||
$this->checkToken();
|
$this->checkToken();
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class SnapshotsController extends AdminController
|
|||||||
{
|
{
|
||||||
$this->checkToken();
|
$this->checkToken();
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
|||||||
@@ -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,57 +278,48 @@ 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 = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Step 3: Remote upload (if configured)
|
/* Step 3: Remote upload — iterate all enabled destinations */
|
||||||
// Wrapped in its own try-catch so a remote failure does not mark
|
$remotes = $this->loadRemoteDestinations($db, $profileId);
|
||||||
// the entire backup as failed — the local archive is preserved.
|
|
||||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
|
||||||
|
|
||||||
if ($remoteStorage !== 'none') {
|
if (!empty($remotes)) {
|
||||||
try {
|
foreach ($remotes as $remote) {
|
||||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
try {
|
||||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
|
||||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
$params = json_decode($remote->params, true) ?: [];
|
||||||
|
$uploader = $this->createUploaderFromParams($remote->type, $params);
|
||||||
|
$result = $uploader->upload($archivePath, $archiveName);
|
||||||
|
|
||||||
if ($uploadResult['success']) {
|
if ($result['success']) {
|
||||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
$this->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
$uploader->upload($restoreScriptPath, basename($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']);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
}
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
// 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;
|
$uploadFailed = true;
|
||||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
$this->log('Local backup is preserved.');
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
}
|
||||||
$uploadFailed = true;
|
|
||||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
/* Delete local copy only when ALL remotes succeeded and profile says so */
|
||||||
$this->log('Local backup is preserved.');
|
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
|
@unlink($archivePath);
|
||||||
|
$this->log('Local copy removed (remote_keep_local = off)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,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,
|
||||||
@@ -370,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);
|
||||||
|
|
||||||
@@ -486,19 +488,54 @@ class BackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate remote uploader based on the storage type.
|
* Create a remote uploader from JSON params.
|
||||||
|
*
|
||||||
|
* Builds a fake profile-like object from the params array so the existing
|
||||||
|
* uploader constructors work without modification.
|
||||||
|
*
|
||||||
|
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||||
|
* @param array $params Key-value params decoded from the remote's JSON
|
||||||
|
*
|
||||||
|
* @return RemoteUploaderInterface
|
||||||
*/
|
*/
|
||||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||||
{
|
{
|
||||||
|
$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($profile),
|
'ftp' => new FtpUploader($fake),
|
||||||
'sftp' => new SftpUploader($profile),
|
'sftp' => new SftpUploader($fake),
|
||||||
'google_drive' => new GoogleDriveUploader($profile),
|
'google_drive' => new GoogleDriveUploader($fake),
|
||||||
's3' => new S3Uploader($profile),
|
's3' => new S3Uploader($fake),
|
||||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load enabled remote destinations for a profile from the remotes table.
|
||||||
|
*/
|
||||||
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
|
{
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the file manifest from the most recent full backup for this profile.
|
* Load the file manifest from the most recent full backup for this profile.
|
||||||
* Used by differential backups to determine which files changed.
|
* Used by differential backups to determine which files changed.
|
||||||
@@ -509,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,65 +216,83 @@ 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>
|
<div id="mr-backup-list"></div>
|
||||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||||
<div id="mr-backup-list"></div>
|
</div>
|
||||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
<script>
|
||||||
</div>
|
(function() {
|
||||||
<script>
|
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||||
(function() {
|
var list = document.getElementById('mr-backup-list');
|
||||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
var hiddenInput = document.getElementById('mr-backup-file');
|
||||||
var list = document.getElementById('mr-backup-list');
|
|
||||||
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: '));
|
||||||
found.appendChild(strong);
|
found.appendChild(strong);
|
||||||
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:';
|
||||||
var label = document.createElement('label');
|
list.appendChild(hint);
|
||||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
backups.forEach(function(b, i) {
|
||||||
var radio = document.createElement('input');
|
var label = document.createElement('label');
|
||||||
radio.type = 'radio';
|
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;';
|
||||||
radio.name = 'backup_choice';
|
label.onmouseover = function() { this.style.background = '#f8fafc'; };
|
||||||
radio.value = b.name;
|
label.onmouseout = function() { this.style.background = ''; };
|
||||||
radio.style.marginRight = '8px';
|
var radio = document.createElement('input');
|
||||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
radio.type = 'radio';
|
||||||
label.appendChild(radio);
|
radio.name = 'backup_choice';
|
||||||
var nameStrong = document.createElement('strong');
|
radio.value = b.name;
|
||||||
nameStrong.textContent = b.name;
|
radio.style.marginRight = '10px';
|
||||||
label.appendChild(nameStrong);
|
if (i === 0) { radio.checked = true; hiddenInput.value = b.name; }
|
||||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||||
group.appendChild(label);
|
label.appendChild(radio);
|
||||||
});
|
var info = document.createElement('div');
|
||||||
list.appendChild(group);
|
var nameStrong = document.createElement('strong');
|
||||||
}
|
nameStrong.textContent = b.name;
|
||||||
})();
|
info.appendChild(nameStrong);
|
||||||
</script>
|
var meta = document.createElement('div');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</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,16 +1444,24 @@ 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)) {
|
||||||
$removed[] = $file;
|
if (@unlink($path)) {
|
||||||
|
$removed[] = $file;
|
||||||
|
error_log('MokoRestore: Cleanup — removed ' . $file);
|
||||||
|
} else {
|
||||||
|
error_log('MokoRestore: Cleanup — FAILED to remove ' . $file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_log('MokoRestore: Cleanup complete — removed ' . count($removed) . ' file(s)');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed))
|
'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed))
|
||||||
@@ -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,42 +1995,66 @@ async function runPreflight() {
|
|||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
log('Running pre-flight checks...');
|
log('Running pre-flight checks...');
|
||||||
|
|
||||||
const r = await post('preflight');
|
try {
|
||||||
const list = document.getElementById('checkList');
|
const r = await post('preflight');
|
||||||
while (list.firstChild) list.removeChild(list.firstChild);
|
|
||||||
|
|
||||||
r.checks.forEach(function(c) {
|
if (!r.success && !r.checks.length) {
|
||||||
const li = document.createElement('li');
|
log('Pre-flight error: ' + (r.message || 'Unknown error'));
|
||||||
const icon = document.createElement('span');
|
setBtnLoading(btn, false);
|
||||||
icon.className = 'mr-check-icon ' + (c.ok ? 'mr-check-ok' : 'mr-check-fail');
|
btn.textContent = 'Re-check';
|
||||||
icon.textContent = c.ok ? '\u2713' : '\u2717';
|
setStatus('checkList', r.message || 'Pre-flight check failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const list = document.getElementById('checkList');
|
||||||
label.className = 'mr-check-label';
|
while (list.firstChild) list.removeChild(list.firstChild);
|
||||||
label.textContent = c.label;
|
|
||||||
|
|
||||||
const val = document.createElement('span');
|
r.checks.forEach(function(c) {
|
||||||
val.className = 'mr-check-value';
|
const li = document.createElement('li');
|
||||||
val.textContent = c.value;
|
const icon = document.createElement('span');
|
||||||
|
var iconClass = c.ok ? 'mr-check-ok' : 'mr-check-fail';
|
||||||
|
if (c.warn) iconClass = 'mr-check-warn';
|
||||||
|
icon.className = 'mr-check-icon ' + iconClass;
|
||||||
|
icon.textContent = c.warn ? '\u26a0' : (c.ok ? '\u2713' : '\u2717');
|
||||||
|
|
||||||
li.appendChild(icon);
|
const label = document.createElement('span');
|
||||||
li.appendChild(label);
|
label.className = 'mr-check-label';
|
||||||
li.appendChild(val);
|
label.textContent = c.label;
|
||||||
list.appendChild(li);
|
|
||||||
|
|
||||||
log(' ' + (c.ok ? 'OK' : 'FAIL') + ': ' + c.label + ' = ' + c.value);
|
const val = document.createElement('span');
|
||||||
});
|
val.className = 'mr-check-value';
|
||||||
|
val.textContent = c.value;
|
||||||
|
|
||||||
setBtnLoading(btn, false);
|
li.appendChild(icon);
|
||||||
|
li.appendChild(label);
|
||||||
|
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);
|
||||||
|
|
||||||
if (r.success) {
|
var logPrefix = c.warn ? 'WARN' : (c.ok ? 'OK' : 'FAIL');
|
||||||
btn.textContent = 'Next \u2192';
|
log(' ' + logPrefix + ': ' + c.label + ' = ' + c.value);
|
||||||
btn.onclick = function() { goStep(2); };
|
});
|
||||||
btn.className = 'mr-btn mr-btn-success';
|
|
||||||
log('All checks passed');
|
setBtnLoading(btn, false);
|
||||||
} else {
|
|
||||||
|
if (r.success) {
|
||||||
|
btn.textContent = 'Next \u2192';
|
||||||
|
btn.onclick = function() { goStep(2); };
|
||||||
|
btn.className = 'mr-btn mr-btn-success';
|
||||||
|
log('All checks passed');
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Re-check';
|
||||||
|
log('Some checks failed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Pre-flight error: ' + e.message);
|
||||||
|
setBtnLoading(btn, false);
|
||||||
btn.textContent = 'Re-check';
|
btn.textContent = 'Re-check';
|
||||||
log('Some checks failed');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,65 +276,76 @@ class PreflightCheck
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check that remote storage credentials are minimally configured.
|
* Check that remote destination credentials are minimally configured.
|
||||||
* Does not test the actual connection (too slow for preflight).
|
* 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) {
|
||||||
case 'ftp':
|
$params = json_decode($remote->params, true) ?: [];
|
||||||
if (empty($profile->ftp_host)) {
|
$label = $remote->title ?: ('Remote #' . $remote->id);
|
||||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($profile->ftp_username)) {
|
switch ($remote->type) {
|
||||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
case 'ftp':
|
||||||
}
|
if (empty($params['host'])) {
|
||||||
|
$this->warnings[] = $label . ': FTP host is not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
if (empty($params['username'])) {
|
||||||
|
$this->warnings[] = $label . ': FTP username is not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
case 's3':
|
break;
|
||||||
if (empty($profile->s3_bucket)) {
|
|
||||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
case 's3':
|
||||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
if (empty($params['bucket'])) {
|
||||||
}
|
$this->warnings[] = $label . ': S3 bucket is not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
if (empty($params['access_key']) || empty($params['secret_key'])) {
|
||||||
|
$this->warnings[] = $label . ': S3 credentials are not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
case 'sftp':
|
break;
|
||||||
if (empty($profile->sftp_host)) {
|
|
||||||
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($profile->sftp_username)) {
|
case 'sftp':
|
||||||
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
if (empty($params['host'])) {
|
||||||
}
|
$this->warnings[] = $label . ': SFTP host is not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
if (empty($params['username'])) {
|
||||||
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
$this->warnings[] = $label . ': SFTP username is not configured — upload will fail';
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
if (empty($params['key_data']) && empty($params['password'])) {
|
||||||
|
$this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
case 'google_drive':
|
break;
|
||||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
|
||||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($profile->gdrive_refresh_token)) {
|
case 'google_drive':
|
||||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
if (empty($params['client_id']) || empty($params['client_secret'])) {
|
||||||
}
|
$this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
if (empty($params['refresh_token'])) {
|
||||||
|
$this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RestoreEngine
|
|||||||
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
|
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,10 +69,14 @@ 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
|
||||||
|
$session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId);
|
||||||
|
$session->remoteIndex = 0;
|
||||||
|
|
||||||
// Resolve placeholders in directory and filename
|
// Resolve placeholders in directory and filename
|
||||||
$resolver = new PlaceholderResolver($profile);
|
$resolver = new PlaceholderResolver($profile);
|
||||||
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
||||||
@@ -147,13 +151,15 @@ class SteppedBackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$totalSteps += 1; // finalize step
|
$totalSteps += 1; // finalize step
|
||||||
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
|
|
||||||
|
$remoteCount = count($session->remoteDestinations);
|
||||||
|
$totalSteps += $remoteCount;
|
||||||
|
|
||||||
$session->totalSteps = $totalSteps;
|
$session->totalSteps = $totalSteps;
|
||||||
$session->currentStep = 1;
|
$session->currentStep = 1;
|
||||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||||
$session->log('Backup initialized: ' . $session->description);
|
$session->log('Backup initialized: ' . $session->description);
|
||||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')');
|
||||||
// Log any preflight warnings into the session
|
// Log any preflight warnings into the session
|
||||||
foreach ($preflightResult['warnings'] as $warning) {
|
foreach ($preflightResult['warnings'] as $warning) {
|
||||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||||
@@ -364,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
|
||||||
@@ -391,7 +412,13 @@ class SteppedBackupEngine
|
|||||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
$session->currentStep++;
|
$session->currentStep++;
|
||||||
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
|
|
||||||
|
if (!empty($session->remoteDestinations)) {
|
||||||
|
$session->phase = 'upload';
|
||||||
|
} else {
|
||||||
|
$session->phase = 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
||||||
$session->log('Archive finalized: ' . $sizeHuman);
|
$session->log('Archive finalized: ' . $sizeHuman);
|
||||||
|
|
||||||
@@ -401,7 +428,7 @@ class SteppedBackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload phase: send archive to remote storage.
|
* Upload phase: send archive to one remote destination per call.
|
||||||
*/
|
*/
|
||||||
private function stepUpload(SteppedSession $session): void
|
private function stepUpload(SteppedSession $session): void
|
||||||
{
|
{
|
||||||
@@ -409,62 +436,68 @@ class SteppedBackupEngine
|
|||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Wrapped in its own try-catch so a remote failure does not mark
|
$index = $session->remoteIndex;
|
||||||
// the entire backup as failed — the local archive is preserved.
|
|
||||||
|
if ($index >= count($session->remoteDestinations)) {
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = 'All remote uploads finished';
|
||||||
|
$this->completeRecord($session);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remote = (object) $session->remoteDestinations[$index];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Reload profile for remote settings
|
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||||
$query = $db->getQuery(true)
|
$type = $remote->type ?? 'unknown';
|
||||||
->select('*')
|
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
||||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$profile = $db->loadObject();
|
|
||||||
|
|
||||||
$uploader = match ($session->remoteStorage) {
|
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||||
'ftp' => new FtpUploader($profile),
|
$uploader = $this->createUploaderFromParams($type, $params);
|
||||||
'sftp' => new SftpUploader($profile),
|
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||||
'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']) {
|
if ($result['success']) {
|
||||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
$session->log('Remote upload complete: ' . $result['message']);
|
$session->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||||
@unlink($session->archivePath);
|
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
|
||||||
$session->log('Local copy removed');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
$session->log('Local backup is preserved.');
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
$session->log('Local backup is preserved.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record with remote filename
|
$session->remoteIndex++;
|
||||||
$update = (object) [
|
|
||||||
'id' => $session->recordId,
|
|
||||||
'remote_filename' => $remoteFilename,
|
|
||||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
|
||||||
|
|
||||||
$session->currentStep++;
|
$session->currentStep++;
|
||||||
$session->phase = 'complete';
|
|
||||||
$session->statusMessage = $uploadFailed
|
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||||
: 'Backup complete';
|
|
||||||
$this->completeRecord($session, $uploadFailed);
|
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||||
|
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
|
@unlink($session->archivePath);
|
||||||
|
$session->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = (object) [
|
||||||
|
'id' => $session->recordId,
|
||||||
|
'remote_filename' => $remoteFilename,
|
||||||
|
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||||
|
];
|
||||||
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = $uploadFailed
|
||||||
|
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||||
|
: 'Backup complete';
|
||||||
|
$this->completeRecord($session, $uploadFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -530,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,
|
||||||
@@ -569,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());
|
||||||
@@ -729,4 +769,61 @@ class SteppedBackupEngine
|
|||||||
return $tables;
|
return $tables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 array Array of remote destination rows (as associative arrays for JSON serialization)
|
||||||
|
*/
|
||||||
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
|
{
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||||
|
*
|
||||||
|
* Builds a fake profile-like object from the params array so the existing
|
||||||
|
* uploader constructors work without modification.
|
||||||
|
*
|
||||||
|
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||||
|
* @param array $params Key-value params decoded from the remote's JSON
|
||||||
|
*
|
||||||
|
* @return RemoteUploaderInterface
|
||||||
|
*/
|
||||||
|
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||||
|
{
|
||||||
|
$prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
|
||||||
|
$prefix = $prefixMap[$type] ?? '';
|
||||||
|
|
||||||
|
$prefixed = [];
|
||||||
|
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
$prefixed[$prefix . $key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fake = (object) $prefixed;
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'ftp' => new FtpUploader($fake),
|
||||||
|
'sftp' => new SftpUploader($fake),
|
||||||
|
'google_drive' => new GoogleDriveUploader($fake),
|
||||||
|
's3' => new S3Uploader($fake),
|
||||||
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 . ')'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user