Compare commits
190 Commits
version/01.35.00
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a027d6245 | |||
| 8af19f875c | |||
| b56e4060bf | |||
| 9757658c34 | |||
| c82378128a | |||
| f95505704a | |||
| 6cdc9b04d0 | |||
| bad73529ae | |||
| 288baf41d3 | |||
| 7d1dcf3e1c | |||
| 2002c1fcad | |||
| 4abe81f916 | |||
| 571b03743f | |||
| 7fc1cad305 | |||
| 03a1dd75c9 | |||
| 02d8312d1b | |||
| c508fcc8d5 | |||
| d104b7b936 | |||
| 80110ac111 | |||
| 3bd1f63833 | |||
| 93f0fa0a47 | |||
| 268b3d54d7 | |||
| 1cfe7c6c6e | |||
| f0da0c02b4 | |||
| 2f8a65388c | |||
| 9978622960 | |||
| 35e5fc1503 | |||
| 2338ba5197 | |||
| e67eedbc93 | |||
| d812aca832 | |||
| 4315f36c6a | |||
| 10467835ac | |||
| f26d58504e | |||
| 07fb4dcc24 | |||
| 21a4352b3b | |||
| 9d26f59f98 | |||
| 3488434f28 | |||
| f97cd30c95 | |||
| 836d1bc8b7 | |||
| 79b3caa35a | |||
| 6102c8f590 | |||
| 88e53c5698 | |||
| ec1c3486c5 | |||
| 3742477aef | |||
| 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 | |||
| 95317fb707 | |||
| cb5ff2843d | |||
| 4e6369094b | |||
| 0fbcc861d9 | |||
| 8cea58d1f6 | |||
| 4df70531e2 | |||
| 845b856cda | |||
| 84511b08d2 | |||
| 899a33bc58 | |||
| 633e9b7f1e | |||
| ec0b7eb8a4 | |||
| 7d119565da | |||
| 7970597fb8 | |||
| 13f1c1db5e | |||
| 7ea30aa146 | |||
| d96f3e7760 | |||
| 10b31fea84 | |||
| 997924a107 | |||
| 9319abec41 | |||
| 7e404b0246 | |||
| 6638577cf5 | |||
| 114995242d | |||
| 3d6c0974fa | |||
| 8aefc1d702 | |||
| da52a9d2f9 | |||
| 0dc0eb1bef | |||
| 1def73df19 | |||
| 48f132ecf9 | |||
| c17349277d | |||
| 5a6ad02b53 | |||
| 29da9776cd | |||
| 09bac755a9 | |||
| f830dc2ddf | |||
| 5698c074da | |||
| aaf189b87a | |||
| 61023821e6 | |||
| 02a6e30db1 | |||
| 5a0cd51df6 | |||
| 12c832d7fe | |||
| 65c8820db4 | |||
| 0f914c3061 | |||
| 4191f44c1b | |||
| fb99afbeba | |||
| de632e9c5c | |||
| 53ff99148c | |||
| c2ff3b272a | |||
| 747b68c179 | |||
| cbff40d04c | |||
| e415e701cd | |||
| d184ed9de0 | |||
| 297f27c807 | |||
| 30e8d7baa9 | |||
| efc5754bef | |||
| e3e422d29e | |||
| 9f5c8c0b5e | |||
| 044e57adf3 | |||
| e7f165ac96 | |||
| fc41e1801a | |||
| 1aa35dd041 | |||
| 6a1f4a8797 | |||
| 6f6a6c705b | |||
| e8d7d1d421 | |||
| cd31617e21 | |||
| 6d9d96d7cd | |||
| df7c07bec4 | |||
| 5b4717bf6f | |||
| 65d30613b2 | |||
| d5bbab7e72 | |||
| 18b65d30ac | |||
| f55b032cc9 | |||
| e62dba8f40 | |||
| 0619825f38 | |||
| 70d7da34b3 | |||
| 13c251196b | |||
| 4841f24eab | |||
| 64ffbb9d61 | |||
| 83e91c6fa6 | |||
| b1833825e7 | |||
| bde20e82ad | |||
| 8348d23fe4 | |||
| d9557489d5 | |||
| 089ec69595 | |||
| 7427cbb043 | |||
| 456e744d81 | |||
| 6d5ef50727 | |||
| 00e7963988 | |||
| bc06657317 | |||
| bda4b0a23d | |||
| e327f9cf5c | |||
| 5b9351e5f0 | |||
| 5785e9fd1e | |||
| 1e9c8d54f4 | |||
| 7515274712 | |||
| 0be459fe34 | |||
| 11ccdbfde4 | |||
| fd517c16f3 | |||
| fe76f81b47 | |||
| 18127454b5 | |||
| 7826c315b1 | |||
| e329dbd99b | |||
| d6b3e8cff0 | |||
| 80c97620a5 | |||
| 33d852bacf | |||
| 8be0500913 | |||
| 27dded6c62 | |||
| e465dfa6ee | |||
| 3ac0318ba3 | |||
| 9db7331a72 | |||
| 32931c1e37 |
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "source/packages/MokoSuiteClient"]
|
||||||
|
path = source/packages/MokoSuiteClient
|
||||||
|
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
|
||||||
@@ -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:
|
||||||
@@ -66,6 +66,7 @@ jobs:
|
|||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
name: "Publish to Composer"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
- '[0-9]*.[0-9]*.[0-9]*'
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish Package
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >-
|
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip publish]')
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
if ! command -v php &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
|
||||||
|
|
||||||
- name: Determine version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Package version: ${VERSION}"
|
|
||||||
|
|
||||||
# Gitea Composer Registry — auto-publishes from tags
|
|
||||||
# The tag push itself registers the package at:
|
|
||||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
|
||||||
- name: Verify Gitea registry
|
|
||||||
run: |
|
|
||||||
echo "Gitea Composer registry auto-publishes from tags."
|
|
||||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
|
||||||
echo "Install: composer require mokoconsulting/mokocli"
|
|
||||||
|
|
||||||
# Packagist — notify of new version
|
|
||||||
- name: Notify Packagist
|
|
||||||
if: secrets.PACKAGIST_TOKEN != ''
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
echo "Notifying Packagist of version ${VERSION}..."
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
|
||||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
|
||||||
&& echo "Packagist notified" \
|
|
||||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
@@ -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
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.35.00
|
# VERSION: 01.43.32
|
||||||
# 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"
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ 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: |
|
||||||
|
if [ -f .gitmodules ]; then
|
||||||
|
git submodule foreach 'git checkout main && git pull origin main' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,82 +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.Security
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
|
||||||
|
|
||||||
name: "Universal: Security Audit"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'composer.json'
|
|
||||||
- 'composer.lock'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
name: Dependency Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Composer audit
|
|
||||||
if: hashFiles('composer.lock') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== Composer Security Audit ==="
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
|
||||||
RESULT=$?
|
|
||||||
if [ $RESULT -ne 0 ]; then
|
|
||||||
echo "::warning::Composer vulnerabilities found"
|
|
||||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "No known vulnerabilities in composer dependencies"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: NPM audit
|
|
||||||
if: hashFiles('package-lock.json') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== NPM Security Audit ==="
|
|
||||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
|
||||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
|
||||||
echo "No known vulnerabilities in npm dependencies"
|
|
||||||
else
|
|
||||||
echo "::warning::NPM vulnerabilities found"
|
|
||||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on vulnerabilities
|
|
||||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
|
||||||
-H "Tags: lock,warning" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
|
||||||
+152
-20
@@ -1,26 +1,158 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.35.00] --- 2026-06-23
|
### Added
|
||||||
|
- Customizable restore script filename per backup profile (reduces discoverability on remote servers)
|
||||||
|
- MokoRestore standalone mode: multi-ZIP selector when multiple backup archives are present
|
||||||
|
- MokoRestore preflight: Joomla installation detection warning before overwriting an existing site
|
||||||
|
- MokoRestore error handling: try/catch on fetch calls, HTTP status checks, JSON parse recovery
|
||||||
|
- Download button on individual backup record detail toolbar
|
||||||
|
- Profile column in backup records list links to the profile edit view
|
||||||
|
|
||||||
## [01.35.00] --- 2026-06-23
|
### Changed
|
||||||
|
- Moved download, browse archive, and view log actions from backup list rows into the individual backup record view
|
||||||
|
- Removed "Run Backup" / "Backup Now" buttons from profiles list, profile edit toolbar, and backup records view (backups are triggered from the dashboard only)
|
||||||
|
- Removed ordering field from profiles; default sort is now by ID ascending
|
||||||
|
- MokoRestore cleanup and security messages now reference the actual script filename instead of hardcoded "restore.php"
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bootstrap 5 modal conversion for snapshots view (data-bs-dismiss, modal-footer, getOrCreateInstance)
|
||||||
|
- ntfy default URL changed from ntfy.sh to ntfy.mokoconsulting.tech
|
||||||
|
- Untranslated JFIELD_ORDERING_ASC / JFIELD_ORDERING_LABEL language keys replaced with component-specific keys
|
||||||
|
- Options page title now shows "MokoSuiteBackup Options" instead of raw language key
|
||||||
|
- Profile dropdown IDs in backup records and dashboard show "#ID — Title (type)" format
|
||||||
|
- MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state
|
||||||
|
|
||||||
|
## [01.43.00] --- 2026-06-24
|
||||||
|
|
||||||
|
|
||||||
|
## [01.43.00] --- 2026-06-24
|
||||||
|
|
||||||
|
## [01.42.00] --- 2026-06-23
|
||||||
|
|
||||||
|
|
||||||
|
## [01.42.00] --- 2026-06-23
|
||||||
|
|
||||||
|
## [01.41.00] — 2026-06-23
|
||||||
|
|
||||||
|
### Added — Multi-Remote Storage
|
||||||
|
- New `#__mokosuitebackup_remotes` table for multiple destinations per profile
|
||||||
|
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit
|
||||||
|
- Engine uploads to ALL enabled destinations (BackupEngine + SteppedBackupEngine)
|
||||||
|
- Migration auto-converts existing SFTP/S3/GDrive/FTP profile columns to new table
|
||||||
|
- Backward compatibility: falls back to legacy single-remote columns if table empty
|
||||||
|
- Secrets masked in API responses, merged from DB on save
|
||||||
|
|
||||||
|
### Added — Content Snapshots
|
||||||
|
- Lightweight JSON snapshots of articles, categories, and modules
|
||||||
|
- Includes tags, custom fields, workflow associations, field values
|
||||||
|
- Restore modes: Replace (clean slate), Merge (upsert), Selective (per-article)
|
||||||
|
- Snapshot retention: max count + max age with automatic cleanup
|
||||||
|
- Scheduled snapshot task via com_scheduler
|
||||||
|
- CLI: `mokosuitebackup:snapshot create|restore|list|delete`
|
||||||
|
- REST API: create, list, restore, delete, download snapshots
|
||||||
|
- Tabbed browse modal: Articles / Categories / Modules with item counts
|
||||||
|
|
||||||
|
### Added — SFTP Remote Storage
|
||||||
|
- SFTP support with SSH key file authentication (key stored base64 in database)
|
||||||
|
- Auth type dropdown: Password / Key File / Key File + Passphrase
|
||||||
|
- SshKeyField: file upload via FileReader, key never exposed in HTML
|
||||||
|
- SFTP remote directory browser for path selection
|
||||||
|
- `__KEEP_EXISTING__` sentinel preserves key on profile re-save
|
||||||
|
|
||||||
|
### Added — MokoRestore Wizard (9 steps)
|
||||||
|
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
|
||||||
|
- Preset buttons: "All Replace", "All Skip", "Everything except users"
|
||||||
|
- Post-restore actions: reset passwords, hits, versions, sessions, cache
|
||||||
|
- Auto-detect sanitized passwords and prompt for reset (random temp password)
|
||||||
|
- Standalone mode: restore.php scans directory for ZIP files
|
||||||
|
- Wrapped mode: restore.php bundled inside backup ZIP
|
||||||
|
- Security gate with filesystem verification + path traversal protection
|
||||||
|
|
||||||
|
### Added — Data Sanitization
|
||||||
|
- Sanitize user passwords: replace hashes with invalid sentinel
|
||||||
|
- Sanitize user emails: replace with dummy values
|
||||||
|
- Clear session data: exclude `#__session` table
|
||||||
|
- Preserve super admin credentials (optional)
|
||||||
|
- GDPR-friendly backup sharing for demos and staging sites
|
||||||
|
|
||||||
|
### Added — Backup Engine
|
||||||
|
- Pre-flight validation: directory, disk space, extensions, credentials, running backups
|
||||||
|
- Auto-verify archive integrity after creation (ZIP, tar.gz, 7z)
|
||||||
|
- 7z archive format via system 7za/7z CLI binary with native encryption
|
||||||
|
- Streaming database dump to temp file (prevents OOM on large sites)
|
||||||
|
- S3 streaming upload via CURLOPT_PUT (prevents OOM)
|
||||||
|
- Graceful remote degradation: local backup preserved if upload fails
|
||||||
|
- DatabaseDumper::dumpToFile() for memory-efficient operation
|
||||||
|
|
||||||
|
### Added — Admin UI
|
||||||
|
- Dashboard: snapshot widget, 30-day backup trend chart, per-profile storage breakdown
|
||||||
|
- CPanel admin dashboard module (mod_mokosuitebackup_cpanel) with quick actions
|
||||||
|
- Backup type filter dropdown in backups list
|
||||||
|
- Backup comparison: select two backups for side-by-side diff
|
||||||
|
- Archive browser: view files inside backup without extracting
|
||||||
|
- Manual purge: delete backups older than a date with count preview
|
||||||
|
- Backup count badges on profile list
|
||||||
|
- "Do not navigate away" warning in backup/restore progress modals
|
||||||
|
- Clickable placeholder pills for backup directory and archive name fields
|
||||||
|
- Comprehensive help modal with absolute/relative/placeholder path documentation
|
||||||
|
- Placeholder resolution display with EXAMPLE prefix
|
||||||
|
- All placeholders UPPERCASE: [HOST], [SITE_NAME], [DATE], [DATETIME], etc.
|
||||||
|
|
||||||
|
### Added — CLI & API
|
||||||
|
- `mokosuitebackup:restore` with --files-only, --db-only, --password options
|
||||||
|
- `mokosuitebackup:snapshot` with create, restore, list, delete actions
|
||||||
|
- REST API for snapshots: create, list, restore, delete, download
|
||||||
|
- Profile credentials masked in API responses
|
||||||
|
|
||||||
|
### Added — Notifications & Logging
|
||||||
|
- Email/ntfy notifications for site restore, snapshot create/restore
|
||||||
|
- Joomla Action Logs for restore, snapshot, and snapshot restore events
|
||||||
|
- Global ntfy server/topic/token settings (fallback for profiles)
|
||||||
|
|
||||||
|
### Added — Security & Configuration
|
||||||
|
- Webcron secret field with CSPRNG generator + strength meter
|
||||||
|
- IP whitelist field with current IP detection + one-click "Add my IP"
|
||||||
|
- 10 ACL permissions with full enforcement audit across all controllers
|
||||||
|
- Config defaults: archive format, MokoRestore mode, sanitization settings
|
||||||
|
- Path traversal protection on all archive extraction (ZIP, tar.gz, JPA)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CLI RestoreCommand passed wrong arguments (filepath instead of record ID)
|
||||||
|
- JPA path traversal: reject `../` in archive entry paths
|
||||||
|
- S3Uploader OOM: streaming upload instead of file_get_contents
|
||||||
|
- DatabaseDumper OOM: streaming to file instead of in-memory string
|
||||||
|
- AkeebaImporter: removed unserialize() (PHP object injection risk)
|
||||||
|
- BackupTable: delete DB row before file (prevents data loss)
|
||||||
|
- RestoreEngine: staging path sanitized with preg_replace
|
||||||
|
- API profiles: sensitive fields masked with `***`
|
||||||
|
- Webcron: missing return after sendJsonResponse on auth failure
|
||||||
|
- loadFormData(): cast array to object (PHP 8.x TypeError fix)
|
||||||
|
- MokoRestore data-only mode: uses REPLACE INTO for existing rows
|
||||||
|
- Plaintext archive deleted on encryption failure
|
||||||
|
- TarGzArchiver: intermediate .tar cleaned in finally block
|
||||||
|
- Install script: single-line comments converted to block comments
|
||||||
|
- Orphaned root-level webservices plugin files removed
|
||||||
|
- include_mokorestore column: TINYINT changed to VARCHAR(20)
|
||||||
|
- Snapshot fields_values: scoped dump and restore to com_content.article (previously destroyed values for contacts, users, etc.)
|
||||||
|
- Run Backup button: accept CSRF token from GET (fixes "token did not match" on profile edit)
|
||||||
|
- SFTP fields: moved into remote fieldset for showon visibility; removed required attr that blocked non-SFTP saves
|
||||||
|
- Script.php merge conflict markers resolved
|
||||||
|
|
||||||
|
## [01.24.00] — 2026-06-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- SFTP remote storage with SSH key file authentication — key stored securely in database
|
- Initial release: full-site backup and restore for Joomla 6
|
||||||
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
|
- Database, files, and configuration backup
|
||||||
|
- ZIP and tar.gz archive formats with AES-256 encryption
|
||||||
## [01.34.00] --- 2026-06-23
|
- Differential backups based on file manifests
|
||||||
|
- FTP/FTPS, S3, Google Drive remote storage
|
||||||
## [01.34.00] --- 2026-06-23
|
- MokoRestore standalone restore wizard
|
||||||
|
- CLI backup and restore commands
|
||||||
### Added
|
- REST API for remote management
|
||||||
- Dashboard: snapshot widget, backup trend chart (30 days), and storage breakdown by profile (#61)
|
- Scheduled tasks via com_scheduler
|
||||||
|
- Email and ntfy push notifications
|
||||||
## [01.33.00] --- 2026-06-23
|
- Per-profile retention, exclusions, and notifications
|
||||||
|
- Akeeba Backup migration tool
|
||||||
## [01.33.00] --- 2026-06-23
|
- Admin dashboard with system health checks
|
||||||
|
|
||||||
### Added
|
|
||||||
- Backup comparison: select two backups to view side-by-side size, file count, and duration differences (#64)
|
|
||||||
- Selective article restore: browse articles inside a snapshot and restore individual items (#58)
|
|
||||||
- Archive browser: view files inside a backup without extracting (#59)
|
|
||||||
|
|||||||
@@ -1,50 +1,85 @@
|
|||||||
# MokoSuiteBackup
|
# MokoSuiteBackup
|
||||||
|
|
||||||
<!-- VERSION: 01.35.00 -->
|
|
||||||
|
|
||||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||||
|
|
||||||
## Overview
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
MokoSuiteBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management.
|
| **Package** | `pkg_mokosuitebackup` |
|
||||||
|
| **Type** | Joomla Package (9 sub-extensions + MokoSuiteClient) |
|
||||||
|
| **Joomla** | 6.x+ |
|
||||||
|
| **PHP** | 8.1+ |
|
||||||
|
| **License** | GPL-3.0-or-later |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Full site backup (database + files + configuration)
|
### Backup
|
||||||
- Database-only backup mode
|
- Full site, database-only, files-only, and differential backup modes
|
||||||
- Files-only backup mode
|
- Pre-flight validation — checks directory, disk space, extensions, credentials before starting
|
||||||
- Multiple backup profiles with independent configurations
|
- Auto-verify archive integrity after creation
|
||||||
- File and directory exclusion filters
|
- Stepped AJAX engine prevents timeout on shared hosting
|
||||||
- Table exclusion filters for database backups
|
- AES-256 ZIP encryption with configurable password
|
||||||
- Step-based backup engine (avoids PHP timeout on large sites)
|
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
|
||||||
- CLI script for cron/scheduled backups
|
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
|
||||||
- REST API (Joomla Web Services) for remote management
|
|
||||||
- Backup record management (list, download, delete)
|
### Content Snapshots
|
||||||
- Automatic old backup cleanup (configurable retention)
|
- Lightweight JSON snapshots of articles, categories, and modules
|
||||||
- Admin dashboard with backup history and storage usage
|
- Includes tags, custom fields, workflow associations
|
||||||
|
- Restore modes: Replace (clean slate) or Merge (upsert)
|
||||||
|
- Selective article restore — browse and pick individual items
|
||||||
|
- Automatic retention (max count + max age)
|
||||||
|
- Scheduled snapshot task via com_scheduler
|
||||||
|
|
||||||
|
### Remote Storage
|
||||||
|
- Multi-remote — upload to multiple destinations per profile simultaneously
|
||||||
|
- SFTP with SSH key file auth + remote directory browser
|
||||||
|
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
|
||||||
|
- Google Drive with OAuth2 and resumable uploads
|
||||||
|
- Graceful degradation — local backup preserved if upload fails
|
||||||
|
|
||||||
|
### MokoRestore Standalone Wizard
|
||||||
|
- 9-step restore wizard that works without Joomla installed
|
||||||
|
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
|
||||||
|
- Post-restore actions: reset passwords, hits, versions, sessions, cache
|
||||||
|
- Auto-detect sanitized passwords and prompt for reset
|
||||||
|
- Standalone mode: restore.php scans directory for ZIP files
|
||||||
|
- Wrapped mode: restore.php bundled inside backup ZIP
|
||||||
|
- Security gate with filesystem verification
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- Email on success/failure per profile
|
||||||
|
- ntfy push notifications
|
||||||
|
- Notifications for restore and snapshot operations
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
- Last backup status, next scheduled, total count, storage used
|
||||||
|
- Snapshot widget with latest info and type badges
|
||||||
|
- 30-day backup trend chart
|
||||||
|
- Per-profile storage breakdown
|
||||||
|
- System health checks
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
- `mokosuitebackup:run --profile=1` — run backup
|
||||||
|
- `mokosuitebackup:restore 1 --files-only --db-only --password=xxx`
|
||||||
|
- `mokosuitebackup:snapshot create|restore|list|delete`
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
- Backup: start, list, download, delete, profiles
|
||||||
|
- Snapshots: create, list, restore, delete, download
|
||||||
|
- Profile credentials masked in API responses
|
||||||
|
|
||||||
|
### Bundled: MokoSuiteClient
|
||||||
|
- Full MokoSuiteClient package installed automatically alongside MokoSuiteBackup
|
||||||
|
- Provides admin dashboard, security firewall, tenant management, and developer tools
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
||||||
2. Joomla Administrator > Extensions > Install
|
2. Joomla Administrator > Extensions > Install
|
||||||
3. System plugin enabled automatically on install
|
3. Components > MokoSuiteBackup > Dashboard
|
||||||
|
|
||||||
## Configuration
|
## Documentation
|
||||||
|
|
||||||
- **Component**: Administrator > Components > MokoSuiteBackup
|
See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
|
||||||
- **Profiles**: Create backup profiles with different file/database filters
|
|
||||||
- **System Plugin**: Configure scheduled backup triggers and notifications
|
|
||||||
- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups
|
|
||||||
|
|
||||||
## REST API
|
|
||||||
|
|
||||||
The webservices plugin exposes endpoints compatible with the MokoBackup MCP server:
|
|
||||||
|
|
||||||
- `POST /api/index.php/v1/mokobackup/backup` — Start a backup
|
|
||||||
- `GET /api/index.php/v1/mokobackup/backups` — List backup records
|
|
||||||
- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive
|
|
||||||
- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record
|
|
||||||
- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+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: 01.43.32
|
||||||
|
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 +0,0 @@
|
|||||||
<!DOCTYPE html><title></title>
|
|
||||||
Submodule
+1
Submodule source/packages/MokoSuiteClient added at 64482e59cd
@@ -12,5 +12,8 @@
|
|||||||
<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" />
|
||||||
</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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
>
|
>
|
||||||
<option value="zip">ZIP</option>
|
<option value="zip">ZIP</option>
|
||||||
<option value="tar.gz">tar.gz</option>
|
<option value="tar.gz">tar.gz</option>
|
||||||
|
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
|
||||||
</field>
|
</field>
|
||||||
<field
|
<field
|
||||||
name="compression_level"
|
name="compression_level"
|
||||||
@@ -72,24 +73,36 @@
|
|||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="archive_name_format"
|
name="archive_name_format"
|
||||||
type="text"
|
type="PlaceholderText"
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||||
default="[host]_[datetime]_profile[profile_id]"
|
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||||
maxlength="512"
|
maxlength="512"
|
||||||
hint="[host]_[datetime]_profile[profile_id]"
|
hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||||
|
placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
|
||||||
|
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="include_mokorestore"
|
name="include_mokorestore"
|
||||||
type="radio"
|
type="list"
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||||
default="0"
|
default="0"
|
||||||
class="btn-group"
|
|
||||||
>
|
>
|
||||||
<option value="1">JYES</option>
|
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||||
<option value="0">JNO</option>
|
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</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"
|
||||||
@@ -99,6 +112,54 @@
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||||
|
<field
|
||||||
|
name="sanitize_passwords"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="preserve_super_admin"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
showon="sanitize_passwords:1"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sanitize_emails"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sanitize_sessions"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||||
<field
|
<field
|
||||||
name="id"
|
name="id"
|
||||||
@@ -113,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">
|
||||||
@@ -151,6 +206,13 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||||
|
<field
|
||||||
|
name="remote_legacy_note"
|
||||||
|
type="note"
|
||||||
|
label=""
|
||||||
|
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
|
||||||
|
class="alert alert-info small"
|
||||||
|
/>
|
||||||
<field
|
<field
|
||||||
name="remote_storage"
|
name="remote_storage"
|
||||||
type="list"
|
type="list"
|
||||||
@@ -159,7 +221,6 @@
|
|||||||
default="none"
|
default="none"
|
||||||
>
|
>
|
||||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
|
||||||
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||||
@@ -175,6 +236,81 @@
|
|||||||
<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">
|
||||||
@@ -340,70 +476,6 @@
|
|||||||
</field>
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="sftp" label="COM_MOKOJOOMBACKUP_FIELDSET_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_password"
|
|
||||||
type="password"
|
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
|
||||||
maxlength="255"
|
|
||||||
showon="remote_storage:sftp"
|
|
||||||
/>
|
|
||||||
<field
|
|
||||||
name="sftp_key_data"
|
|
||||||
type="textarea"
|
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
|
||||||
rows="6"
|
|
||||||
cols="60"
|
|
||||||
filter="raw"
|
|
||||||
showon="remote_storage:sftp"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<field
|
|
||||||
name="sftp_path"
|
|
||||||
type="text"
|
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
|
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
|
|
||||||
default="/backups"
|
|
||||||
maxlength="512"
|
|
||||||
showon="remote_storage:sftp"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
|
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
|
||||||
<field
|
<field
|
||||||
name="gdrive_client_id"
|
name="gdrive_client_id"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
; @license GPL-3.0-or-later
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||||
|
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
|
||||||
@@ -41,6 +42,8 @@ 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_N_ITEMS_DELETED="%d backup records deleted."
|
||||||
|
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted."
|
||||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup 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."
|
||||||
@@ -78,6 +81,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
|||||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||||
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
||||||
|
|
||||||
|
; Profile actions
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||||
|
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||||
|
|
||||||
; Table headings
|
; Table headings
|
||||||
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
||||||
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
||||||
@@ -113,6 +122,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
|
|||||||
; Archive settings
|
; Archive settings
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
|
||||||
|
COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
|
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
||||||
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
|
||||||
@@ -120,15 +130,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="Include Restore Script"
|
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
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_WRAPPED="Wrapped (inside backup ZIP)"
|
||||||
|
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
|
||||||
|
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||||
|
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_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_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_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"
|
||||||
@@ -254,9 +280,19 @@ 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="Paste the contents of 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_REPLACE="Replace Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
||||||
@@ -383,6 +419,38 @@ 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."
|
||||||
|
|
||||||
; 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."
|
||||||
@@ -405,6 +473,42 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
|
|||||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||||
|
|
||||||
|
; Snapshot browse / detail view
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
|
||||||
|
COM_MOKOJOOMBACKUP_LOADING="Loading..."
|
||||||
|
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
|
||||||
|
|
||||||
|
; Purge
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected 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_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||||
|
|
||||||
|
; 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."
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup 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."
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||||
|
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
||||||
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||||
@@ -99,3 +103,16 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
|||||||
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||||
|
|
||||||
|
; Purge
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
|
||||||
|
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected 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_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<extension type="component" method="upgrade">
|
||||||
<name>MokoSuiteBackup</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
||||||
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
||||||
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
|
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
|
||||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
|
||||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
`exclude_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',
|
||||||
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||||
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||||
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`sftp_key_data` MEDIUMTEXT,
|
`sftp_key_data` MEDIUMTEXT,
|
||||||
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
@@ -38,7 +39,12 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
`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` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||||
|
`restore_script_name` VARCHAR(100) NOT NULL DEFAULT 'restore.php' COMMENT 'Custom restore script filename',
|
||||||
|
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||||
|
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||||
|
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||||
|
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
`notify_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,
|
||||||
@@ -102,6 +108,21 @@ 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`,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
|||||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||||
|
|
||||||
-- Add archive_name_format column with placeholder support
|
-- Add archive_name_format column with placeholder support
|
||||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
|
||||||
|
-- Needed to support 'standalone' value alongside 0/1
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
|
||||||
|
|
||||||
|
UPDATE `#__mokosuitebackup_profiles` SET
|
||||||
|
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||||
|
`archive_name_format`,
|
||||||
|
'[host]', '[HOST]'),
|
||||||
|
'[site_name]', '[SITE_NAME]'),
|
||||||
|
'[datetime]', '[DATETIME]'),
|
||||||
|
'[date]', '[DATE]'),
|
||||||
|
'[time]', '[TIME]'),
|
||||||
|
'[year]', '[YEAR]'),
|
||||||
|
'[month]', '[MONTH]'),
|
||||||
|
'[day]', '[DAY]'),
|
||||||
|
'[hour]', '[HOUR]'),
|
||||||
|
'[minute]', '[MINUTE]'),
|
||||||
|
'[second]', '[SECOND]'),
|
||||||
|
'[profile_id]', '[PROFILE_ID]'),
|
||||||
|
'[profile_name]', '[PROFILE_NAME]'),
|
||||||
|
'[type]', '[TYPE]'),
|
||||||
|
'[random]', '[RANDOM]')
|
||||||
|
WHERE `archive_name_format` REGEXP '\\[[a-z]';
|
||||||
|
|
||||||
|
UPDATE `#__mokosuitebackup_profiles` SET
|
||||||
|
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||||
|
`backup_dir`,
|
||||||
|
'[host]', '[HOST]'),
|
||||||
|
'[site_name]', '[SITE_NAME]'),
|
||||||
|
'[date]', '[DATE]'),
|
||||||
|
'[year]', '[YEAR]'),
|
||||||
|
'[month]', '[MONTH]'),
|
||||||
|
'[day]', '[DAY]'),
|
||||||
|
'[profile_id]', '[PROFILE_ID]'),
|
||||||
|
'[profile_name]', '[PROFILE_NAME]')
|
||||||
|
WHERE `backup_dir` REGEXP '\\[[a-z]';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||||
|
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||||
|
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||||
|
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||||
@@ -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 */
|
||||||
@@ -15,8 +15,10 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
use Joomla\CMS\Session\Session;
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||||
@@ -283,7 +285,32 @@ class AjaxController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolved = BackupDirectory::resolve($rawPath);
|
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
|
||||||
|
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
|
||||||
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
|
||||||
|
if ($profileId > 0) {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$profile = $db->loadObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($profile)) {
|
||||||
|
/* No profile context — create a minimal dummy for PlaceholderResolver */
|
||||||
|
$profile = (object) [
|
||||||
|
'id' => 1,
|
||||||
|
'title' => 'default',
|
||||||
|
'backup_type' => 'full',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = new PlaceholderResolver($profile);
|
||||||
|
$withNamePlaceholders = $resolver->resolve($rawPath);
|
||||||
|
$resolved = BackupDirectory::resolve($withNamePlaceholders);
|
||||||
|
|
||||||
if (BackupDirectory::hasPlaceholders($resolved)) {
|
if (BackupDirectory::hasPlaceholders($resolved)) {
|
||||||
$this->sendJson([
|
$this->sendJson([
|
||||||
@@ -321,7 +348,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;
|
||||||
@@ -357,7 +384,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;
|
||||||
@@ -389,7 +416,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;
|
||||||
@@ -622,15 +649,19 @@ class AjaxController extends BaseController
|
|||||||
|
|
||||||
$data = json_decode($json, true);
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tables = $data['tables'] ?? [];
|
||||||
|
|
||||||
|
// Articles
|
||||||
$articles = [];
|
$articles = [];
|
||||||
|
|
||||||
foreach ($data['tables']['#__content'] as $row) {
|
if (!empty($tables['#__content'])) {
|
||||||
|
foreach ($tables['#__content'] as $row) {
|
||||||
$articles[] = [
|
$articles[] = [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
'title' => $row['title'] ?? '',
|
'title' => $row['title'] ?? '',
|
||||||
@@ -639,11 +670,97 @@ class AjaxController extends BaseController
|
|||||||
'created' => $row['created'] ?? '',
|
'created' => $row['created'] ?? '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
if (!empty($tables['#__categories'])) {
|
||||||
|
foreach ($tables['#__categories'] as $row) {
|
||||||
|
$categories[] = [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'title' => $row['title'] ?? '',
|
||||||
|
'extension' => $row['extension'] ?? '',
|
||||||
|
'published' => (int) ($row['published'] ?? 0),
|
||||||
|
'level' => (int) ($row['level'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modules
|
||||||
|
$modules = [];
|
||||||
|
|
||||||
|
if (!empty($tables['#__modules'])) {
|
||||||
|
foreach ($tables['#__modules'] as $row) {
|
||||||
|
$modules[] = [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'title' => $row['title'] ?? '',
|
||||||
|
'module' => $row['module'] ?? '',
|
||||||
|
'position' => $row['position'] ?? '',
|
||||||
|
'published' => (int) ($row['published'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->sendJson([
|
$this->sendJson([
|
||||||
'error' => false,
|
'error' => false,
|
||||||
'articles' => $articles,
|
'articles' => $articles,
|
||||||
'total' => count($articles),
|
'categories' => $categories,
|
||||||
|
'modules' => $modules,
|
||||||
|
'total_articles' => \count($articles),
|
||||||
|
'total_categories' => \count($categories),
|
||||||
|
'total_modules' => \count($modules),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count backup records that would be purged before a given date.
|
||||||
|
* POST: task=ajax.countPurge&date=2025-01-01
|
||||||
|
*/
|
||||||
|
public function countPurge(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $this->input->getString('date', '');
|
||||||
|
|
||||||
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = $date . ' 00:00:00';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
|
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||||
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$count = (int) $db->loadResult();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'count' => $count,
|
||||||
|
'date' => $date,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +776,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;
|
||||||
@@ -762,6 +879,509 @@ class AjaxController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Remote Destinations CRUD
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List remote destinations for a profile.
|
||||||
|
* POST: task=ajax.listRemotes&profile_id=1
|
||||||
|
*/
|
||||||
|
public function listRemotes(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
|
||||||
|
if (!$profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||||
|
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
$rows = $db->loadObjectList();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode JSON params and mask secrets
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Title is required']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 {
|
||||||
|
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$table->load($id);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if ((int) $table->profile_id !== $profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$table->profile_id = $profileId;
|
||||||
|
$table->title = $title;
|
||||||
|
$table->type = $type;
|
||||||
|
$table->enabled = $enabled ? 1 : 0;
|
||||||
|
$table->params = json_encode($config);
|
||||||
|
if (!$table->check() || !$table->store()) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = [
|
||||||
|
'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 = [
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse directories on a remote SFTP server for the path picker.
|
||||||
|
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
||||||
|
*/
|
||||||
|
public function browseSftpDir(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profileId = $this->input->getInt('profile_id', 0);
|
||||||
|
|
||||||
|
if (!$profileId) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load the profile to get SFTP credentials */
|
||||||
|
try {
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$profile = $db->loadObject();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$profile) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = $profile->sftp_host ?? '';
|
||||||
|
$port = (int) ($profile->sftp_port ?? 22);
|
||||||
|
$username = $profile->sftp_username ?? '';
|
||||||
|
$keyData = $profile->sftp_key_data ?? '';
|
||||||
|
$password = $profile->sftp_password ?? '';
|
||||||
|
|
||||||
|
if (empty($host) || empty($username)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($keyData) && empty($password)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestPath = $this->input->getString('path', '/');
|
||||||
|
|
||||||
|
/* Sanitize: must start with / and not contain shell meta-characters */
|
||||||
|
$requestPath = '/' . ltrim($requestPath, '/');
|
||||||
|
|
||||||
|
if (preg_match('/[;&|`$<>]/', $requestPath)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* Write temp key if using key auth (same pattern as SftpUploader) */
|
||||||
|
if (!empty($keyData)) {
|
||||||
|
$keyContent = base64_decode($keyData, true);
|
||||||
|
|
||||||
|
if ($keyContent === false) {
|
||||||
|
$keyContent = $keyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
|
||||||
|
|
||||||
|
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||||
|
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||||
|
}
|
||||||
|
|
||||||
|
chmod($keyFile, 0600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build SSH command to list directories */
|
||||||
|
$escapedPath = escapeshellarg($requestPath);
|
||||||
|
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
|
||||||
|
|
||||||
|
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
||||||
|
|
||||||
|
if ($port !== 22) {
|
||||||
|
$parts[] = '-p';
|
||||||
|
$parts[] = (string) $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyFile !== null) {
|
||||||
|
$parts[] = '-i';
|
||||||
|
$parts[] = escapeshellarg($keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($username . '@' . $host);
|
||||||
|
$parts[] = escapeshellarg($remoteCmd);
|
||||||
|
|
||||||
|
$cmd = implode(' ', $parts);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
/* exitCode 1 from grep means no matches (empty dir), which is OK */
|
||||||
|
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||||
|
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse output: each line is a directory name ending with / */
|
||||||
|
$dirs = [];
|
||||||
|
|
||||||
|
foreach ($output as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
if ($line === '' || $line === './' || $line === '../') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dirName = rtrim($line, '/');
|
||||||
|
|
||||||
|
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
|
||||||
|
|
||||||
|
$dirs[] = [
|
||||||
|
'name' => $dirName,
|
||||||
|
'path' => $fullPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
/* Parent path */
|
||||||
|
$parent = null;
|
||||||
|
|
||||||
|
if ($requestPath !== '/') {
|
||||||
|
$parent = \dirname($requestPath);
|
||||||
|
|
||||||
|
if ($parent === '') {
|
||||||
|
$parent = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'current' => $requestPath,
|
||||||
|
'parent' => $parent,
|
||||||
|
'dirs' => $dirs,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
|
||||||
|
} finally {
|
||||||
|
if ($keyFile !== null && is_file($keyFile)) {
|
||||||
|
unlink($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a JSON response and close the application.
|
* Send a JSON response and close the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
use Joomla\CMS\MVC\Controller\AdminController;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
||||||
|
|
||||||
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
|
|||||||
*/
|
*/
|
||||||
public function start(): void
|
public function start(): void
|
||||||
{
|
{
|
||||||
$this->checkToken();
|
/* Accept token from both GET (profile Run button) and POST (backup form).
|
||||||
|
Joomla's checkToken() throws on failure, so try GET first. */
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
@@ -157,6 +165,88 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge (delete) all completed backup records older than a given date.
|
||||||
|
*
|
||||||
|
* Deletes archive files, log files, and database records.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function purge(): void
|
||||||
|
{
|
||||||
|
$this->checkToken();
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoffDate = $this->input->getString('purge_date', '');
|
||||||
|
|
||||||
|
if (empty($cutoffDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $cutoffDate)) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = $cutoffDate . ' 00:00:00';
|
||||||
|
|
||||||
|
$db = $this->app->getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('id'))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
|
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||||
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$ids = $db->loadColumn();
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'), 'warning');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $this->getModel('Backup')->getTable();
|
||||||
|
$deleted = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if ($table->load((int) $id)) {
|
||||||
|
if ($table->delete()) {
|
||||||
|
$deleted++;
|
||||||
|
} else {
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$table->reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors > 0) {
|
||||||
|
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_PARTIAL', $deleted, $errors), 'warning');
|
||||||
|
} else {
|
||||||
|
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_SUCCESS', $deleted));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op target for the purge toolbar button.
|
||||||
|
*
|
||||||
|
* The toolbar button needs a task so Joomla does not complain,
|
||||||
|
* but the actual purge is triggered via the modal form which
|
||||||
|
* submits to backups.purge. This method simply redirects back.
|
||||||
|
*/
|
||||||
|
public function purgeModal(): void
|
||||||
|
{
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify integrity of a backup archive by re-computing SHA-256.
|
* Verify integrity of a backup archive by re-computing SHA-256.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,14 @@ class BackupEngine
|
|||||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||||
$archiveName = '';
|
$archiveName = '';
|
||||||
$archiver = $this->createArchiver($archiveFormat);
|
$archiver = $this->createArchiver($archiveFormat);
|
||||||
|
|
||||||
|
// Pass encryption password to 7z archiver (handles it natively via -p flag)
|
||||||
|
if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
|
||||||
|
$archiver->setEncryptionPassword($profile->encryption_password);
|
||||||
|
}
|
||||||
|
|
||||||
$archiveExt = $archiver->getExtension();
|
$archiveExt = $archiver->getExtension();
|
||||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||||
|
|
||||||
if (empty($description)) {
|
if (empty($description)) {
|
||||||
@@ -137,7 +143,19 @@ class BackupEngine
|
|||||||
if ($profile->backup_type !== 'files') {
|
if ($profile->backup_type !== 'files') {
|
||||||
$this->log('Starting database dump...');
|
$this->log('Starting database dump...');
|
||||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||||
$dumper = new DatabaseDumper($excludeTables);
|
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
||||||
|
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
||||||
|
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
||||||
|
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
||||||
|
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
||||||
|
|
||||||
|
if ($sanitizePasswords) {
|
||||||
|
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sanitizeEmails) {
|
||||||
|
$this->log('User emails will be sanitized');
|
||||||
|
}
|
||||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||||
$tablesCount = $dumper->getTablesCount();
|
$tablesCount = $dumper->getTablesCount();
|
||||||
@@ -216,12 +234,14 @@ class BackupEngine
|
|||||||
$encryptionPassword = $profile->encryption_password ?? '';
|
$encryptionPassword = $profile->encryption_password ?? '';
|
||||||
|
|
||||||
if (!empty($encryptionPassword)) {
|
if (!empty($encryptionPassword)) {
|
||||||
if ($archiveFormat !== 'zip') {
|
if ($archiveFormat === 'zip') {
|
||||||
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
|
|
||||||
} else {
|
|
||||||
$this->log('Encrypting archive with AES-256...');
|
$this->log('Encrypting archive with AES-256...');
|
||||||
$this->encryptArchive($archivePath, $encryptionPassword);
|
$this->encryptArchive($archivePath, $encryptionPassword);
|
||||||
$this->log('Archive encrypted');
|
$this->log('Archive encrypted');
|
||||||
|
} elseif ($archiveFormat === '7z') {
|
||||||
|
$this->log('Archive encrypted with AES-256 (7z native encryption)');
|
||||||
|
} else {
|
||||||
|
$this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,34 +257,72 @@ class BackupEngine
|
|||||||
$this->verifyArchive($archivePath, $profile->backup_type);
|
$this->verifyArchive($archivePath, $profile->backup_type);
|
||||||
$this->log('Archive integrity verified');
|
$this->log('Archive integrity verified');
|
||||||
|
|
||||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
// Step 2.5: MokoRestore script (if enabled)
|
||||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||||
|
$restoreScriptName = $profile->restore_script_name ?? 'restore.php';
|
||||||
|
$restoreScriptPath = '';
|
||||||
|
|
||||||
if ($includeMokoRestore) {
|
if ($mokoRestoreMode === '1') {
|
||||||
$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);
|
||||||
|
|
||||||
// Replace the original archive with the wrapped one
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
rename($mokoRestorePath, $archivePath);
|
rename($mokoRestorePath, $archivePath);
|
||||||
$totalSize = filesize($archivePath);
|
$totalSize = filesize($archivePath);
|
||||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||||
// Recompute checksum for the final wrapped archive
|
|
||||||
$checksum = hash_file('sha256', $archivePath);
|
$checksum = hash_file('sha256', $archivePath);
|
||||||
$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') {
|
||||||
|
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||||
|
$this->log('Generating standalone ' . $restoreScriptName . '...');
|
||||||
|
$restoreScriptPath = $this->backupDir . '/' . $restoreScriptName;
|
||||||
|
MokoRestore::generateStandalone($restoreScriptPath);
|
||||||
|
$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.
|
|
||||||
|
if (!empty($remotes)) {
|
||||||
|
foreach ($remotes as $remote) {
|
||||||
|
try {
|
||||||
|
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
|
||||||
|
$params = json_decode($remote->params, true) ?: [];
|
||||||
|
$uploader = $this->createUploaderFromParams($remote->type, $params);
|
||||||
|
$result = $uploader->upload($archivePath, $archiveName);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||||
|
$this->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
|
$uploader->upload($restoreScriptPath, basename($restoreScriptPath));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete local copy only when ALL remotes succeeded and profile says so */
|
||||||
|
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
|
@unlink($archivePath);
|
||||||
|
$this->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Backward-compat: fall back to legacy single-remote column */
|
||||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||||
|
|
||||||
if ($remoteStorage !== 'none') {
|
if ($remoteStorage !== 'none') {
|
||||||
@@ -277,6 +335,18 @@ class BackupEngine
|
|||||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||||
|
|
||||||
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
|
$restoreBasename = basename($restoreScriptPath);
|
||||||
|
$this->log('Uploading standalone ' . $restoreBasename . '...');
|
||||||
|
$restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename);
|
||||||
|
|
||||||
|
if ($restoreUpload['success']) {
|
||||||
|
$this->log('Standalone ' . $restoreBasename . ' uploaded');
|
||||||
|
} else {
|
||||||
|
$this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete local copy if configured
|
// Delete local copy if configured
|
||||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
@unlink($archivePath);
|
@unlink($archivePath);
|
||||||
@@ -293,10 +363,11 @@ class BackupEngine
|
|||||||
$this->log('Local backup is preserved.');
|
$this->log('Local backup is preserved.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write log file alongside the archive
|
// Write log file alongside the archive
|
||||||
$logContent = implode("\n", $this->log);
|
$logContent = implode("\n", $this->log);
|
||||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
$logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
|
||||||
if (@file_put_contents($logPath, $logContent) === false) {
|
if (@file_put_contents($logPath, $logContent) === false) {
|
||||||
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
||||||
}
|
}
|
||||||
@@ -442,12 +513,15 @@ class BackupEngine
|
|||||||
return match ($format) {
|
return match ($format) {
|
||||||
'zip' => new ZipArchiver(),
|
'zip' => new ZipArchiver(),
|
||||||
'tar.gz' => new TarGzArchiver(),
|
'tar.gz' => new TarGzArchiver(),
|
||||||
|
'7z' => new SevenZipArchiver(),
|
||||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate remote uploader based on the storage type.
|
* Create the appropriate remote uploader based on the storage type.
|
||||||
|
* Legacy method — used by backward-compat fallback when remotes table
|
||||||
|
* does not exist.
|
||||||
*/
|
*/
|
||||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||||
{
|
{
|
||||||
@@ -460,6 +534,59 @@ class BackupEngine
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$fake = (object) $params;
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load enabled remote destinations for a profile from the remotes table.
|
||||||
|
*
|
||||||
|
* Returns an empty array when the table does not exist (pre-migration)
|
||||||
|
* so the caller can fall back to the legacy single-remote column.
|
||||||
|
*
|
||||||
|
* @param object $db Database driver
|
||||||
|
* @param int $profileId Profile ID
|
||||||
|
*
|
||||||
|
* @return object[] Array of remote destination rows
|
||||||
|
*/
|
||||||
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -547,6 +674,13 @@ class BackupEngine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7z verification via CLI
|
||||||
|
if ($extension === '7z') {
|
||||||
|
$this->verify7zArchive($archivePath);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ZIP verification
|
// ZIP verification
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
|
|
||||||
@@ -608,6 +742,64 @@ class BackupEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a 7z archive using the CLI binary.
|
||||||
|
*
|
||||||
|
* @param string $archivePath Absolute path to the .7z file
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException If the archive fails verification
|
||||||
|
*/
|
||||||
|
private function verify7zArchive(string $archivePath): void
|
||||||
|
{
|
||||||
|
// Test the archive with 7z t (test integrity)
|
||||||
|
$candidates = PHP_OS_FAMILY === 'Windows'
|
||||||
|
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
||||||
|
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
||||||
|
|
||||||
|
$binary = null;
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
||||||
|
if (is_file($candidate) && is_executable($candidate)) {
|
||||||
|
$binary = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
||||||
|
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
||||||
|
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
||||||
|
|
||||||
|
$result = trim((string) shell_exec($whichCmd));
|
||||||
|
|
||||||
|
if ($result !== '' && is_executable($result)) {
|
||||||
|
$binary = $result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($binary === null) {
|
||||||
|
// Cannot verify without the binary — log warning but don't fail
|
||||||
|
$this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Archive integrity check failed: 7z test exited with code ' . $exitCode
|
||||||
|
. ': ' . implode("\n", array_slice($output, -5))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
|
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,12 +27,35 @@ class DatabaseDumper
|
|||||||
|
|
||||||
private int $tablesCount = 0;
|
private int $tablesCount = 0;
|
||||||
|
|
||||||
|
/** @var bool Whether to sanitize user passwords */
|
||||||
|
private bool $sanitizePasswords = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to preserve super admin password when sanitizing */
|
||||||
|
private bool $preserveSuperAdmin = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to sanitize user emails */
|
||||||
|
private bool $sanitizeEmails = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to clear session data */
|
||||||
|
private bool $sanitizeSessions = false;
|
||||||
|
|
||||||
|
/** Known invalid bcrypt hash used for sanitized passwords */
|
||||||
|
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||||
* Supports suffixes: :data-only, :structure-only.
|
* @param bool $sanitizePasswords Replace user password hashes with invalid value
|
||||||
* No suffix = exclude both (backward compatible).
|
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
|
||||||
|
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
|
||||||
|
* @param bool $sanitizeSessions Skip session table data entirely
|
||||||
*/
|
*/
|
||||||
public function __construct(array $excludeTables = [])
|
public function __construct(
|
||||||
|
array $excludeTables = [],
|
||||||
|
bool $sanitizePasswords = false,
|
||||||
|
bool $preserveSuperAdmin = false,
|
||||||
|
bool $sanitizeEmails = false,
|
||||||
|
bool $sanitizeSessions = false
|
||||||
|
)
|
||||||
{
|
{
|
||||||
foreach ($excludeTables as $entry) {
|
foreach ($excludeTables as $entry) {
|
||||||
if (str_ends_with($entry, ':data-only')) {
|
if (str_ends_with($entry, ':data-only')) {
|
||||||
@@ -43,6 +66,16 @@ class DatabaseDumper
|
|||||||
$this->excludeBoth[] = $entry;
|
$this->excludeBoth[] = $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->sanitizePasswords = $sanitizePasswords;
|
||||||
|
$this->preserveSuperAdmin = $preserveSuperAdmin;
|
||||||
|
$this->sanitizeEmails = $sanitizeEmails;
|
||||||
|
$this->sanitizeSessions = $sanitizeSessions;
|
||||||
|
|
||||||
|
/* If session sanitization is on, auto-exclude session table data */
|
||||||
|
if ($sanitizeSessions) {
|
||||||
|
$this->excludeDataOnly[] = '#__session';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +187,7 @@ class DatabaseDumper
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$this->sanitizeRow($row, $abstractName, $db);
|
||||||
$values = [];
|
$values = [];
|
||||||
|
|
||||||
foreach ($row as $value) {
|
foreach ($row as $value) {
|
||||||
@@ -326,6 +360,7 @@ class DatabaseDumper
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$this->sanitizeRow($row, $abstractName, $db);
|
||||||
$values = [];
|
$values = [];
|
||||||
|
|
||||||
foreach ($row as $value) {
|
foreach ($row as $value) {
|
||||||
@@ -351,6 +386,86 @@ class DatabaseDumper
|
|||||||
return filesize($filePath) ?: 0;
|
return filesize($filePath) ?: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a row if it belongs to the users table and sanitization is enabled.
|
||||||
|
*
|
||||||
|
* Replaces the password column with an invalid hash so the backup
|
||||||
|
* cannot be used to extract user credentials.
|
||||||
|
*/
|
||||||
|
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
|
||||||
|
{
|
||||||
|
if ($abstractTable !== '#__users') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
|
||||||
|
$userId = (int) $row['id'];
|
||||||
|
|
||||||
|
/* Preserve super admin emails if preserving super admin */
|
||||||
|
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
|
||||||
|
$row['email'] = 'user' . $userId . '@sanitized.example.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->sanitizePasswords || !isset($row['password'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->preserveSuperAdmin && isset($row['id'])) {
|
||||||
|
if ($this->isSuperAdmin((int) $row['id'], $db)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['password'] = self::SANITIZED_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user ID belongs to the Super Users group (group_id = 8).
|
||||||
|
*/
|
||||||
|
private function isSuperAdmin(int $userId, object $db): bool
|
||||||
|
{
|
||||||
|
static $superAdminIds = null;
|
||||||
|
|
||||||
|
if ($superAdminIds === null) {
|
||||||
|
$prefix = $db->getPrefix();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||||
|
->from($db->quoteName($prefix . 'user_usergroup_map'))
|
||||||
|
->where($db->quoteName('group_id') . ' = 8')
|
||||||
|
);
|
||||||
|
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$superAdminIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($userId, $superAdminIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if passwords were sanitized (for use by callers to log the action).
|
||||||
|
*/
|
||||||
|
public function isPasswordSanitizationEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->sanitizePasswords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sentinel hash used for sanitized passwords.
|
||||||
|
*/
|
||||||
|
public static function getSanitizedHash(): string
|
||||||
|
{
|
||||||
|
return self::SANITIZED_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTablesCount(): int
|
public function getTablesCount(): int
|
||||||
{
|
{
|
||||||
return $this->tablesCount;
|
return $this->tablesCount;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
*
|
*
|
||||||
* Resolves placeholders like [host], [date], [profile_name] in backup
|
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
|
||||||
* directory paths and archive filename formats.
|
* directory paths and archive filename formats.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,21 +24,21 @@ class PlaceholderResolver
|
|||||||
* Supported placeholders and their descriptions (for documentation).
|
* Supported placeholders and their descriptions (for documentation).
|
||||||
*/
|
*/
|
||||||
public const PLACEHOLDERS = [
|
public const PLACEHOLDERS = [
|
||||||
'[host]' => 'Server hostname',
|
'[HOST]' => 'Server hostname',
|
||||||
'[date]' => 'Date as Ymd (e.g. 20260604)',
|
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
|
||||||
'[time]' => 'Time as His (e.g. 143025)',
|
'[TIME]' => 'Time as His (e.g. 143025)',
|
||||||
'[datetime]' => 'Date and time as Ymd_His',
|
'[DATETIME]' => 'Date and time as Ymd_His',
|
||||||
'[year]' => 'Four-digit year',
|
'[YEAR]' => 'Four-digit year',
|
||||||
'[month]' => 'Two-digit month',
|
'[MONTH]' => 'Two-digit month',
|
||||||
'[day]' => 'Two-digit day',
|
'[DAY]' => 'Two-digit day',
|
||||||
'[hour]' => 'Two-digit hour (24h)',
|
'[HOUR]' => 'Two-digit hour (24h)',
|
||||||
'[minute]' => 'Two-digit minute',
|
'[MINUTE]' => 'Two-digit minute',
|
||||||
'[second]' => 'Two-digit second',
|
'[SECOND]' => 'Two-digit second',
|
||||||
'[profile_id]' => 'Backup profile ID',
|
'[PROFILE_ID]' => 'Backup profile ID',
|
||||||
'[profile_name]' => 'Profile title (sanitized)',
|
'[PROFILE_NAME]' => 'Profile title (sanitized)',
|
||||||
'[site_name]' => 'Joomla site name (sanitized)',
|
'[SITE_NAME]' => 'Joomla site name (sanitized)',
|
||||||
'[type]' => 'Backup type (full, database, files, differential)',
|
'[TYPE]' => 'Backup type (full, database, files, differential)',
|
||||||
'[random]' => 'Random 6-character hex string',
|
'[RANDOM]' => 'Random 6-character hex string',
|
||||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||||
'[HOME]' => 'Home directory of the PHP process owner',
|
'[HOME]' => 'Home directory of the PHP process owner',
|
||||||
];
|
];
|
||||||
@@ -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 = '';
|
||||||
|
|
||||||
@@ -62,21 +87,21 @@ class PlaceholderResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->replacements = [
|
$this->replacements = [
|
||||||
'[host]' => $hostname,
|
'[HOST]' => $hostname,
|
||||||
'[date]' => $now->format('Ymd'),
|
'[DATE]' => $now->format('Ymd'),
|
||||||
'[time]' => $now->format('His'),
|
'[TIME]' => $now->format('His'),
|
||||||
'[datetime]' => $now->format('Ymd_His'),
|
'[DATETIME]' => $now->format('Ymd_His'),
|
||||||
'[year]' => $now->format('Y'),
|
'[YEAR]' => $now->format('Y'),
|
||||||
'[month]' => $now->format('m'),
|
'[MONTH]' => $now->format('m'),
|
||||||
'[day]' => $now->format('d'),
|
'[DAY]' => $now->format('d'),
|
||||||
'[hour]' => $now->format('H'),
|
'[HOUR]' => $now->format('H'),
|
||||||
'[minute]' => $now->format('i'),
|
'[MINUTE]' => $now->format('i'),
|
||||||
'[second]' => $now->format('s'),
|
'[SECOND]' => $now->format('s'),
|
||||||
'[profile_id]' => (string) ($profile->id ?? '0'),
|
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
|
||||||
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
|
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
|
||||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
|
||||||
'[type]' => $profile->backup_type ?? 'full',
|
'[TYPE]' => $profile->backup_type ?? 'full',
|
||||||
'[random]' => bin2hex(random_bytes(3)),
|
'[RANDOM]' => bin2hex(random_bytes(3)),
|
||||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||||
];
|
];
|
||||||
@@ -103,7 +128,7 @@ class PlaceholderResolver
|
|||||||
*/
|
*/
|
||||||
public function getHostname(): string
|
public function getHostname(): string
|
||||||
{
|
{
|
||||||
return $this->replacements['[host]'];
|
return $this->replacements['[HOST]'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +136,7 @@ class PlaceholderResolver
|
|||||||
*/
|
*/
|
||||||
public function getTag(): string
|
public function getTag(): string
|
||||||
{
|
{
|
||||||
return $this->replacements['[datetime]'];
|
return $this->replacements['[DATETIME]'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class RestoreEngine
|
class RestoreEngine
|
||||||
{
|
{
|
||||||
@@ -166,6 +167,9 @@ class RestoreEngine
|
|||||||
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterRestore(true, $recordId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||||
@@ -185,6 +189,9 @@ class RestoreEngine
|
|||||||
$this->recursiveDelete($this->stagingDir);
|
$this->recursiveDelete($this->stagingDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterRestore(false, $recordId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||||
@@ -285,6 +292,26 @@ class RestoreEngine
|
|||||||
@rmdir($dir);
|
@rmdir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterRestore(bool $success, int $recordId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterRestore', [
|
||||||
|
'success' => $success,
|
||||||
|
'record_id' => $recordId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the restore result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 7z archiver using the 7za/7z CLI binary.
|
||||||
|
*
|
||||||
|
* Requires p7zip-full (Linux) or 7-Zip (Windows) to be installed on the server.
|
||||||
|
* Supports native AES-256 encryption via the -p flag.
|
||||||
|
*/
|
||||||
|
class SevenZipArchiver implements ArchiverInterface
|
||||||
|
{
|
||||||
|
/** @var string Absolute path to the target archive */
|
||||||
|
private string $archivePath = '';
|
||||||
|
|
||||||
|
/** @var string[] Absolute paths of files to add */
|
||||||
|
private array $filePaths = [];
|
||||||
|
|
||||||
|
/** @var string[] Corresponding local names inside the archive */
|
||||||
|
private array $localNames = [];
|
||||||
|
|
||||||
|
/** @var string[] Temp files created by addFromString() that must be cleaned up */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
/** @var string Optional encryption password */
|
||||||
|
private string $encryptionPassword = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the encryption password for the archive.
|
||||||
|
*
|
||||||
|
* @param string $password Password for AES-256 encryption
|
||||||
|
*/
|
||||||
|
public function setEncryptionPassword(string $password): void
|
||||||
|
{
|
||||||
|
$this->encryptionPassword = $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function open(string $path): void
|
||||||
|
{
|
||||||
|
$this->archivePath = $path;
|
||||||
|
$this->filePaths = [];
|
||||||
|
$this->localNames = [];
|
||||||
|
$this->tempFiles = [];
|
||||||
|
|
||||||
|
// Remove existing archive to avoid appending to stale data
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addFromString(string $localName, string $contents): void
|
||||||
|
{
|
||||||
|
// Write to a temp file so 7z can read it from disk
|
||||||
|
$tempDir = \dirname($this->archivePath);
|
||||||
|
$tempFile = $tempDir . '/.7z-tmp-' . md5($localName . microtime(true)) . '-' . basename($localName);
|
||||||
|
|
||||||
|
if (file_put_contents($tempFile, $contents) === false) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: cannot write temp file: ' . $tempFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tempFiles[] = $tempFile;
|
||||||
|
$this->filePaths[] = $tempFile;
|
||||||
|
$this->localNames[] = $localName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addFile(string $filePath, string $localName): void
|
||||||
|
{
|
||||||
|
$this->filePaths[] = $filePath;
|
||||||
|
$this->localNames[] = $localName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->buildArchive();
|
||||||
|
} finally {
|
||||||
|
// Always clean up temp files
|
||||||
|
foreach ($this->tempFiles as $tempFile) {
|
||||||
|
if (is_file($tempFile)) {
|
||||||
|
@unlink($tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tempFiles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExtension(): string
|
||||||
|
{
|
||||||
|
return '7z';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the 7z archive using the CLI binary.
|
||||||
|
*
|
||||||
|
* Writes a list file mapping local names to absolute paths, then invokes
|
||||||
|
* 7za/7z to create the archive. Uses stdin rename pairs for correct
|
||||||
|
* internal paths.
|
||||||
|
*/
|
||||||
|
private function buildArchive(): void
|
||||||
|
{
|
||||||
|
$binary = $this->findBinary();
|
||||||
|
|
||||||
|
if ($binary === null) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'SevenZipArchiver: 7z/7za binary not found. '
|
||||||
|
. 'Install p7zip-full (Linux) or 7-Zip (Windows).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->filePaths)) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: no files to archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy: create a temporary staging directory with the correct
|
||||||
|
// directory structure, symlink or copy files, then archive the
|
||||||
|
// staging directory. This gives us correct internal paths.
|
||||||
|
$stagingDir = \dirname($this->archivePath) . '/.7z-staging-' . md5($this->archivePath . microtime(true));
|
||||||
|
|
||||||
|
if (!mkdir($stagingDir, 0755, true)) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: cannot create staging directory: ' . $stagingDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the directory structure and link/copy files
|
||||||
|
foreach ($this->filePaths as $i => $sourcePath) {
|
||||||
|
$localName = $this->localNames[$i];
|
||||||
|
$targetPath = $stagingDir . '/' . $localName;
|
||||||
|
$targetDir = \dirname($targetPath);
|
||||||
|
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: cannot create directory: ' . $targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use symlink where possible (faster, no disk usage), fall back to copy
|
||||||
|
if (@symlink($sourcePath, $targetPath) === false) {
|
||||||
|
if (!copy($sourcePath, $targetPath)) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: cannot copy file: ' . $sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build command
|
||||||
|
$cmd = escapeshellarg($binary)
|
||||||
|
. ' a'
|
||||||
|
. ' -t7z'
|
||||||
|
. ' -mx=5'
|
||||||
|
. ' -mhe=on'
|
||||||
|
. ' ' . escapeshellarg($this->archivePath)
|
||||||
|
. ' ' . escapeshellarg($stagingDir . '/*');
|
||||||
|
|
||||||
|
// Add encryption if password is set
|
||||||
|
if ($this->encryptionPassword !== '') {
|
||||||
|
$cmd .= ' -p' . escapeshellarg($this->encryptionPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress interactive prompts
|
||||||
|
$cmd .= ' -y';
|
||||||
|
|
||||||
|
// Redirect stderr to stdout for capture
|
||||||
|
$cmd .= ' 2>&1';
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
$outputStr = implode("\n", $output);
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'SevenZipArchiver: 7z exited with code ' . $exitCode . ': ' . $outputStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($this->archivePath)) {
|
||||||
|
throw new \RuntimeException('SevenZipArchiver: archive was not created: ' . $this->archivePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive contains paths relative to the staging dir.
|
||||||
|
// We need to verify that the internal structure doesn't include
|
||||||
|
// the staging dir name as a prefix. If 7z was given staging/*,
|
||||||
|
// the paths inside should be correct (relative to staging).
|
||||||
|
} finally {
|
||||||
|
// Remove staging directory
|
||||||
|
$this->removeDirectory($stagingDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the 7z or 7za binary.
|
||||||
|
*
|
||||||
|
* @return string|null Absolute path to binary, or null if not found
|
||||||
|
*/
|
||||||
|
private function findBinary(): ?string
|
||||||
|
{
|
||||||
|
// Check common binary names
|
||||||
|
$candidates = PHP_OS_FAMILY === 'Windows'
|
||||||
|
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
||||||
|
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
// If it's an absolute path, check file existence
|
||||||
|
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
||||||
|
if (is_file($candidate) && is_executable($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 'which' / 'where' to find in PATH
|
||||||
|
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
||||||
|
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
||||||
|
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
||||||
|
|
||||||
|
$result = trim((string) shell_exec($whichCmd));
|
||||||
|
|
||||||
|
if ($result !== '' && is_executable($result)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively remove a directory and its contents.
|
||||||
|
*/
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
@rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
// Remove symlinks and files
|
||||||
|
@unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,7 +141,15 @@ class SftpUploader implements RemoteUploaderInterface
|
|||||||
$tmpDir = sys_get_temp_dir();
|
$tmpDir = sys_get_temp_dir();
|
||||||
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
||||||
|
|
||||||
if (file_put_contents($keyFile, $this->keyData) === false) {
|
/* Key is stored base64-encoded in the database — decode before writing */
|
||||||
|
$keyContent = base64_decode($this->keyData, true);
|
||||||
|
|
||||||
|
if ($keyContent === false) {
|
||||||
|
/* Fallback: might be raw PEM (legacy or paste) */
|
||||||
|
$keyContent = $this->keyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||||
throw new \RuntimeException('Cannot write temporary SSH key file');
|
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
|
|||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class SnapshotEngine
|
class SnapshotEngine
|
||||||
{
|
{
|
||||||
@@ -214,6 +215,9 @@ class SnapshotEngine
|
|||||||
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf(
|
'message' => sprintf(
|
||||||
@@ -227,6 +231,9 @@ class SnapshotEngine
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log('FATAL: ' . $e->getMessage());
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||||
@@ -327,6 +334,27 @@ class SnapshotEngine
|
|||||||
return $db->loadAssocList() ?: [];
|
return $db->loadAssocList() ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
||||||
|
'success' => $success,
|
||||||
|
'snapshot_id' => $snapshotId,
|
||||||
|
'content_types' => $contentTypes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the snapshot result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class SnapshotRestoreEngine
|
class SnapshotRestoreEngine
|
||||||
{
|
{
|
||||||
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
|
|||||||
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||||
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
|
|||||||
|
|
||||||
$this->log('FATAL: ' . $e->getMessage());
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||||
@@ -537,6 +544,9 @@ class SnapshotRestoreEngine
|
|||||||
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||||
@@ -553,6 +563,9 @@ class SnapshotRestoreEngine
|
|||||||
|
|
||||||
$this->log('FATAL: ' . $e->getMessage());
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||||
@@ -561,6 +574,27 @@ class SnapshotRestoreEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
|
||||||
|
'success' => $success,
|
||||||
|
'snapshot_id' => $snapshotId,
|
||||||
|
'mode' => $mode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the restore result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -70,9 +70,14 @@ class SteppedBackupEngine
|
|||||||
$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->remoteStorage = $profile->remote_storage ?? 'none';
|
||||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$session->includeMokoRestore = $profile->include_mokorestore ?? '0';
|
||||||
|
$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));
|
||||||
@@ -83,7 +88,19 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$tag = $resolver->getTag();
|
$tag = $resolver->getTag();
|
||||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||||
|
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||||
|
|
||||||
|
// The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
|
||||||
|
// supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
|
||||||
|
if ($archiveFormat !== 'zip') {
|
||||||
|
return [
|
||||||
|
'error' => true,
|
||||||
|
'message' => 'The stepped backup engine only supports ZIP format. '
|
||||||
|
. 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||||
|
|
||||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||||
@@ -135,13 +152,22 @@ class SteppedBackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$totalSteps += 1; // finalize step
|
$totalSteps += 1; // finalize step
|
||||||
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
|
|
||||||
|
// Determine upload step count: one step per remote destination,
|
||||||
|
// or one step for legacy single-remote, or zero if no remotes.
|
||||||
|
$remoteCount = count($session->remoteDestinations);
|
||||||
|
|
||||||
|
if ($remoteCount > 0) {
|
||||||
|
$totalSteps += $remoteCount;
|
||||||
|
} elseif ($session->remoteStorage !== 'none') {
|
||||||
|
$totalSteps += 1;
|
||||||
|
}
|
||||||
|
|
||||||
$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);
|
||||||
@@ -352,15 +378,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
|
||||||
@@ -379,7 +420,17 @@ 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';
|
|
||||||
|
// Determine next phase: multi-remote, legacy single-remote, or complete
|
||||||
|
$hasMultiRemote = !empty($session->remoteDestinations);
|
||||||
|
$hasLegacyRemote = $session->remoteStorage !== 'none';
|
||||||
|
|
||||||
|
if ($hasMultiRemote || $hasLegacyRemote) {
|
||||||
|
$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);
|
||||||
|
|
||||||
@@ -390,6 +441,10 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload phase: send archive to remote storage.
|
* Upload phase: send archive to remote storage.
|
||||||
|
*
|
||||||
|
* When multi-remote destinations are configured, each call uploads to
|
||||||
|
* one destination (one step per remote). When only the legacy
|
||||||
|
* single-remote column is set, uploads in a single step.
|
||||||
*/
|
*/
|
||||||
private function stepUpload(SteppedSession $session): void
|
private function stepUpload(SteppedSession $session): void
|
||||||
{
|
{
|
||||||
@@ -397,8 +452,75 @@ class SteppedBackupEngine
|
|||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Wrapped in its own try-catch so a remote failure does not mark
|
if (!empty($session->remoteDestinations)) {
|
||||||
// the entire backup as failed — the local archive is preserved.
|
// ── Multi-remote path ──────────────────────────────────
|
||||||
|
$index = $session->remoteIndex;
|
||||||
|
|
||||||
|
if ($index >= count($session->remoteDestinations)) {
|
||||||
|
// All remotes processed — move to complete
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = 'All remote uploads finished';
|
||||||
|
$this->completeRecord($session);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remote = (object) $session->remoteDestinations[$index];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||||
|
$type = $remote->type ?? 'unknown';
|
||||||
|
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||||
|
|
||||||
|
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||||
|
$uploader = $this->createUploaderFromParams($type, $params);
|
||||||
|
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
|
$session->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
|
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||||
|
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->remoteIndex++;
|
||||||
|
$session->currentStep++;
|
||||||
|
|
||||||
|
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||||
|
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||||
|
|
||||||
|
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||||
|
// All remotes done — delete local if configured and no failures
|
||||||
|
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
|
@unlink($session->archivePath);
|
||||||
|
$session->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update record with remote filename
|
||||||
|
$update = (object) [
|
||||||
|
'id' => $session->recordId,
|
||||||
|
'remote_filename' => $remoteFilename,
|
||||||
|
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||||
|
];
|
||||||
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = $uploadFailed
|
||||||
|
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||||
|
: 'Backup complete';
|
||||||
|
$this->completeRecord($session, $uploadFailed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Legacy single-remote fallback ──────────────────────
|
||||||
try {
|
try {
|
||||||
// Reload profile for remote settings
|
// Reload profile for remote settings
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
@@ -423,6 +545,12 @@ class SteppedBackupEngine
|
|||||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
$session->log('Remote upload complete: ' . $result['message']);
|
$session->log('Remote upload complete: ' . $result['message']);
|
||||||
|
|
||||||
|
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||||
|
$restoreBasename = basename($session->restoreScriptPath);
|
||||||
|
$session->log('Uploading standalone ' . $restoreBasename . '...');
|
||||||
|
$uploader->upload($session->restoreScriptPath, $restoreBasename);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
@unlink($session->archivePath);
|
@unlink($session->archivePath);
|
||||||
$session->log('Local copy removed');
|
$session->log('Local copy removed');
|
||||||
@@ -454,6 +582,7 @@ class SteppedBackupEngine
|
|||||||
: 'Backup complete';
|
: 'Backup complete';
|
||||||
$this->completeRecord($session, $uploadFailed);
|
$this->completeRecord($session, $uploadFailed);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify that a backup archive can be opened and contains expected entries.
|
* Verify that a backup archive can be opened and contains expected entries.
|
||||||
@@ -717,4 +846,58 @@ 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
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
// Use loadAssocList so the data survives JSON serialization in SteppedSession
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$fake = (object) $params;
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,16 @@ class SteppedSession
|
|||||||
public array $excludeFiles = [];
|
public array $excludeFiles = [];
|
||||||
public array $excludeTables = [];
|
public array $excludeTables = [];
|
||||||
public string $remoteStorage = 'none';
|
public string $remoteStorage = 'none';
|
||||||
public bool $includeMokoRestore = false;
|
public string $includeMokoRestore = '0';
|
||||||
|
public string $restoreScriptName = 'restore.php';
|
||||||
|
public string $restoreScriptPath = '';
|
||||||
public bool $remoteKeepLocal = true;
|
public bool $remoteKeepLocal = true;
|
||||||
public string $encryptionPassword = '';
|
public string $encryptionPassword = '';
|
||||||
|
|
||||||
|
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
|
||||||
|
public array $remoteDestinations = [];
|
||||||
|
public int $remoteIndex = 0;
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
public int $totalSteps = 0;
|
public int $totalSteps = 0;
|
||||||
public int $currentStep = 0;
|
public int $currentStep = 0;
|
||||||
|
|||||||
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build placeholder map for JS resolution
|
// Build placeholder map for JS resolution
|
||||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
/* Resolve hostname: prefer HTTP_HOST, then Joomla live_site config, then system hostname */
|
||||||
|
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawHost) || $rawHost === 'localhost') {
|
||||||
|
try {
|
||||||
|
$liveSite = Factory::getApplication()->get('live_site', '');
|
||||||
|
|
||||||
|
if (!empty($liveSite)) {
|
||||||
|
$parsed = parse_url($liveSite, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!empty($parsed)) {
|
||||||
|
$rawHost = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rawHost)) {
|
||||||
|
$rawHost = php_uname('n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
|
||||||
$siteName = '';
|
$siteName = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,15 +75,15 @@ class FolderPickerField extends FormField
|
|||||||
$placeholders = [
|
$placeholders = [
|
||||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||||
'[host]' => $hostname,
|
'[HOST]' => $hostname,
|
||||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
|
||||||
'[profile_id]' => '1',
|
'[PROFILE_ID]' => '1',
|
||||||
'[profile_name]' => 'default',
|
'[PROFILE_NAME]' => 'default',
|
||||||
'[type]' => 'full',
|
'[TYPE]' => 'full',
|
||||||
'[year]' => date('Y'),
|
'[YEAR]' => date('Y'),
|
||||||
'[month]' => date('m'),
|
'[MONTH]' => date('m'),
|
||||||
'[day]' => date('d'),
|
'[DAY]' => date('d'),
|
||||||
'[date]' => date('Ymd'),
|
'[DATE]' => date('Ymd'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$placeholdersJson = json_encode($placeholders);
|
$placeholdersJson = json_encode($placeholders);
|
||||||
@@ -96,52 +119,141 @@ class FolderPickerField extends FormField
|
|||||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
|
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
|
||||||
<span class="icon-question-circle" aria-hidden="true"></span>
|
<span class="icon-question-circle" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||||
|
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
|
||||||
|
</div>
|
||||||
<div class="mt-1" id="{$id}_status">
|
<div class="mt-1" id="{$id}_status">
|
||||||
<small class="{$statusClass}">
|
<small class="{$statusClass}">
|
||||||
<span class="{$statusIcon}" aria-hidden="true"></span>
|
<span class="{$statusIcon}" aria-hidden="true"></span>
|
||||||
{$statusDetail}
|
{$statusDetail}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
|
||||||
|
</div>
|
||||||
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
||||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
|
|
||||||
<table class="table table-sm table-striped">
|
<h6 class="text-primary">How Path Resolution Works</h6>
|
||||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Absolute Paths</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><code>/home/user/backups</code> — Fixed path on the server</li>
|
||||||
|
<li><code>/var/backups/joomla</code> — System backup directory</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Relative Paths</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
|
||||||
|
<table class="table table-sm mb-2">
|
||||||
|
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||||
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
|
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
|
||||||
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
|
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
|
||||||
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
|
|
||||||
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
|
|
||||||
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
|
|
||||||
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
|
|
||||||
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
|
|
||||||
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
|
|
||||||
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h6>Recommended Paths</h6>
|
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
|
||||||
<ul class="list-unstyled">
|
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
|
||||||
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li>
|
</div>
|
||||||
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li>
|
</div>
|
||||||
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
|
||||||
|
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
|
||||||
|
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-primary mt-3">Available Placeholders</h6>
|
||||||
|
<table class="table table-sm table-striped">
|
||||||
|
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||||
|
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||||
|
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
|
||||||
|
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
|
||||||
|
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
|
||||||
|
<tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
|
||||||
|
<tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
|
||||||
|
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
|
||||||
|
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
|
||||||
|
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
|
||||||
|
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h6 class="text-primary mt-3">Recommended Configurations</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Single site, secure</strong></td>
|
||||||
|
<td><code>[HOME]/backups</code></td>
|
||||||
|
<td>Outside web root. Best for most sites.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Multiple sites on one server</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[HOST]</code></td>
|
||||||
|
<td>Each site gets its own subdirectory.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Date-organized</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
|
||||||
|
<td>Backups sorted by year and month.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Per-profile</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
|
||||||
|
<td>Separate directory for each backup profile.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Shared hosting (default)</strong></td>
|
||||||
|
<td><code>[DEFAULT_DIR]</code></td>
|
||||||
|
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="alert alert-info py-2 mt-3 mb-0">
|
||||||
|
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +267,56 @@ class FolderPickerField extends FormField
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
/* Clickable placeholder insertion at cursor position */
|
||||||
|
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = document.getElementById(this.getAttribute('data-field'));
|
||||||
|
var ph = this.getAttribute('data-ph');
|
||||||
|
if (!target) return;
|
||||||
|
var start = target.selectionStart || 0;
|
||||||
|
var end = target.selectionEnd || 0;
|
||||||
|
var val = target.value;
|
||||||
|
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||||
|
/* Move cursor to after the inserted placeholder */
|
||||||
|
var newPos = start + ph.length;
|
||||||
|
target.setSelectionRange(newPos, newPos);
|
||||||
|
target.focus();
|
||||||
|
/* Trigger input event so status updates */
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Help button — open modal with Bootstrap 5 or fallback */
|
||||||
|
var helpBtn = document.getElementById('{$id}_helpBtn');
|
||||||
|
var helpModal = document.getElementById('{$id}_helpModal');
|
||||||
|
if (helpBtn && helpModal) {
|
||||||
|
helpBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||||
|
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
|
||||||
|
modal.show();
|
||||||
|
} else {
|
||||||
|
helpModal.classList.add('show');
|
||||||
|
helpModal.style.display = 'block';
|
||||||
|
helpModal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
var backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'modal-backdrop fade show';
|
||||||
|
backdrop.id = '{$id}_backdrop';
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
|
||||||
|
helpModal.classList.remove('show');
|
||||||
|
helpModal.style.display = 'none';
|
||||||
|
helpModal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
var bd = document.getElementById('{$id}_backdrop');
|
||||||
|
if (bd) bd.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var fieldId = '{$id}';
|
var fieldId = '{$id}';
|
||||||
var btn = document.getElementById(fieldId + '_btn');
|
var btn = document.getElementById(fieldId + '_btn');
|
||||||
var browser = document.getElementById(fieldId + '_browser');
|
var browser = document.getElementById(fieldId + '_browser');
|
||||||
@@ -162,7 +324,7 @@ class FolderPickerField extends FormField
|
|||||||
var input = document.getElementById(fieldId);
|
var input = document.getElementById(fieldId);
|
||||||
var placeholders = {$placeholdersJson};
|
var placeholders = {$placeholdersJson};
|
||||||
|
|
||||||
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
// Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
|
||||||
function resolve(path) {
|
function resolve(path) {
|
||||||
for (var key in placeholders) {
|
for (var key in placeholders) {
|
||||||
path = path.split(key).join(placeholders[key]);
|
path = path.split(key).join(placeholders[key]);
|
||||||
@@ -253,8 +415,54 @@ class FolderPickerField extends FormField
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show which placeholders are in use and their resolved values */
|
||||||
|
var resolvedDiv = document.getElementById(fieldId + '_resolved');
|
||||||
|
|
||||||
|
function updateResolvedDisplay() {
|
||||||
|
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
|
||||||
|
var val = input.value || '';
|
||||||
|
var found = false;
|
||||||
|
|
||||||
|
for (var key in placeholders) {
|
||||||
|
if (val.indexOf(key) !== -1 && placeholders[key]) {
|
||||||
|
found = true;
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge bg-light text-dark border me-1 mb-1';
|
||||||
|
badge.style.fontSize = '0.75rem';
|
||||||
|
badge.style.fontFamily = 'monospace';
|
||||||
|
|
||||||
|
var keySpan = document.createElement('strong');
|
||||||
|
keySpan.textContent = key;
|
||||||
|
badge.appendChild(keySpan);
|
||||||
|
|
||||||
|
badge.appendChild(document.createTextNode(' = '));
|
||||||
|
|
||||||
|
var valSpan = document.createElement('span');
|
||||||
|
valSpan.className = 'text-primary';
|
||||||
|
valSpan.textContent = placeholders[key];
|
||||||
|
badge.appendChild(valSpan);
|
||||||
|
|
||||||
|
resolvedDiv.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
var fullResolved = document.createElement('div');
|
||||||
|
fullResolved.className = 'mt-1';
|
||||||
|
var arrow = document.createElement('span');
|
||||||
|
arrow.className = 'text-muted';
|
||||||
|
arrow.textContent = 'EXAMPLE: ';
|
||||||
|
fullResolved.appendChild(arrow);
|
||||||
|
var code = document.createElement('code');
|
||||||
|
code.textContent = resolve(val);
|
||||||
|
fullResolved.appendChild(code);
|
||||||
|
resolvedDiv.appendChild(fullResolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
input.addEventListener('input', function() {
|
input.addEventListener('input', function() {
|
||||||
clearTimeout(checkTimer);
|
clearTimeout(checkTimer);
|
||||||
|
updateResolvedDisplay();
|
||||||
checkTimer = setTimeout(checkDirPermissions, 400);
|
checkTimer = setTimeout(checkDirPermissions, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,6 +576,7 @@ class FolderPickerField extends FormField
|
|||||||
|
|
||||||
// Run initial check on page load
|
// Run initial check on page load
|
||||||
setDefaultDirWarning();
|
setDefaultDirWarning();
|
||||||
|
updateResolvedDisplay();
|
||||||
checkDirPermissions();
|
checkDirPermissions();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?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
|
||||||
|
*
|
||||||
|
* Text field with clickable placeholder pills that insert at cursor position.
|
||||||
|
* Used for backup directory and archive name format fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
|
||||||
|
class PlaceholderTextField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'PlaceholderText';
|
||||||
|
|
||||||
|
protected function getInput(): string
|
||||||
|
{
|
||||||
|
$value = htmlspecialchars($this->value ?? $this->default ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||||
|
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||||
|
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$max = (int) ($this->element['maxlength'] ?? 512);
|
||||||
|
|
||||||
|
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
|
||||||
|
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
|
||||||
|
|
||||||
|
if (empty($placeholders)) {
|
||||||
|
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
|
||||||
|
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
|
||||||
|
. ' class="form-control" maxlength="' . $max . '"'
|
||||||
|
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
|
||||||
|
|
||||||
|
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
|
||||||
|
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
|
||||||
|
|
||||||
|
foreach ($placeholders as $ph) {
|
||||||
|
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
|
||||||
|
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
|
||||||
|
. htmlspecialchars($ph) . '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= <<<JS
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = document.getElementById(this.getAttribute('data-field'));
|
||||||
|
var ph = this.getAttribute('data-ph');
|
||||||
|
if (!target) return;
|
||||||
|
var start = target.selectionStart || 0;
|
||||||
|
var end = target.selectionEnd || 0;
|
||||||
|
var val = target.value;
|
||||||
|
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||||
|
var newPos = start + ph.length;
|
||||||
|
target.setSelectionRange(newPos, newPos);
|
||||||
|
target.focus();
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
JS;
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*
|
||||||
|
* SFTP remote path field with Browse Remote button and modal directory browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
|
||||||
|
class SftpPathField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'SftpPath';
|
||||||
|
|
||||||
|
protected function getInput(): string
|
||||||
|
{
|
||||||
|
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
|
||||||
|
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||||
|
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||||
|
class="form-control" maxlength="512"
|
||||||
|
placeholder="/backups" />
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
|
||||||
|
title="Browse directories on the remote SFTP server">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
Browse Remote
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="{$id}_sftpModalLabel">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
Browse Remote SFTP Directory
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="{$id}_sftpStatus" class="mb-2">
|
||||||
|
<small class="text-muted">Click "Browse Remote" to connect...</small>
|
||||||
|
</div>
|
||||||
|
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
|
||||||
|
/
|
||||||
|
</div>
|
||||||
|
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
|
||||||
|
<br>SFTP credentials must be saved in the profile before browsing.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
|
||||||
|
<span class="icon-checkmark" aria-hidden="true"></span>
|
||||||
|
Select This Directory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var fieldId = '{$id}';
|
||||||
|
var input = document.getElementById(fieldId);
|
||||||
|
var browseBtn = document.getElementById(fieldId + '_browseBtn');
|
||||||
|
var modalEl = document.getElementById(fieldId + '_sftpModal');
|
||||||
|
var treeEl = document.getElementById(fieldId + '_sftpTree');
|
||||||
|
var statusEl = document.getElementById(fieldId + '_sftpStatus');
|
||||||
|
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
|
||||||
|
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
|
||||||
|
var currentPath = '/';
|
||||||
|
|
||||||
|
function getProfileId() {
|
||||||
|
var el = document.getElementById('jform_id');
|
||||||
|
return el ? parseInt(el.value, 10) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||||
|
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal() {
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||||
|
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modal) modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the status message using safe DOM methods (no innerHTML).
|
||||||
|
* @param {string} cssClass - CSS class for the small element
|
||||||
|
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
|
||||||
|
* @param {string} text - Plain text message
|
||||||
|
*/
|
||||||
|
function setStatus(cssClass, iconClass, text) {
|
||||||
|
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||||
|
var small = document.createElement('small');
|
||||||
|
small.className = cssClass;
|
||||||
|
if (iconClass) {
|
||||||
|
var icon = document.createElement('span');
|
||||||
|
icon.className = iconClass;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
small.appendChild(icon);
|
||||||
|
small.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
small.appendChild(document.createTextNode(text));
|
||||||
|
statusEl.appendChild(small);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSftpDir(path) {
|
||||||
|
currentPath = path;
|
||||||
|
currentEl.textContent = path;
|
||||||
|
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||||
|
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
|
||||||
|
|
||||||
|
var profileId = getProfileId();
|
||||||
|
if (!profileId) {
|
||||||
|
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = new URLSearchParams();
|
||||||
|
form.append('task', 'ajax.browseSftpDir');
|
||||||
|
form.append('profile_id', profileId);
|
||||||
|
form.append('path', path);
|
||||||
|
|
||||||
|
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||||
|
if (tokenName) form.append(tokenName, '1');
|
||||||
|
|
||||||
|
fetch('index.php?option=com_mokosuitebackup&format=json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
setStatus('text-danger', 'icon-warning', data.message || 'Error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var count = data.dirs ? data.dirs.length : 0;
|
||||||
|
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
|
||||||
|
currentPath = data.current || path;
|
||||||
|
currentEl.textContent = currentPath;
|
||||||
|
renderSftpTree(data);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
setStatus('text-danger', 'icon-warning', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSftpTree(data) {
|
||||||
|
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||||
|
var list = document.createElement('div');
|
||||||
|
list.className = 'list-group list-group-flush';
|
||||||
|
|
||||||
|
/* Parent / back button */
|
||||||
|
if (data.parent !== null && data.parent !== undefined) {
|
||||||
|
var up = document.createElement('a');
|
||||||
|
up.href = '#';
|
||||||
|
up.className = 'list-group-item list-group-item-action py-1';
|
||||||
|
var upIcon = document.createElement('span');
|
||||||
|
upIcon.className = 'icon-arrow-up-4';
|
||||||
|
upIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
up.appendChild(upIcon);
|
||||||
|
up.appendChild(document.createTextNode(' .. (parent directory)'));
|
||||||
|
up.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
loadSftpDir(data.parent);
|
||||||
|
});
|
||||||
|
list.appendChild(up);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directory entries */
|
||||||
|
var dirs = data.dirs || [];
|
||||||
|
|
||||||
|
dirs.forEach(function(dir) {
|
||||||
|
var item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
item.className = 'list-group-item list-group-item-action py-1';
|
||||||
|
var folderIcon = document.createElement('span');
|
||||||
|
folderIcon.className = 'icon-folder';
|
||||||
|
folderIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
item.appendChild(folderIcon);
|
||||||
|
item.appendChild(document.createTextNode(' ' + dir.name));
|
||||||
|
|
||||||
|
item.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
loadSftpDir(dir.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Double-click to select and close */
|
||||||
|
item.addEventListener('dblclick', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
input.value = dir.path;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dirs.length === 0) {
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.className = 'list-group-item text-muted py-2';
|
||||||
|
empty.textContent = '(no subdirectories)';
|
||||||
|
list.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
treeEl.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browse button click */
|
||||||
|
browseBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var startPath = input.value.trim() || '/';
|
||||||
|
showModal();
|
||||||
|
loadSftpDir(startPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Select button — use the current directory */
|
||||||
|
selectBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
input.value = currentPath;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*
|
||||||
|
* Custom field for SSH private key input.
|
||||||
|
* Supports both file upload (via FileReader JS) and paste-in textarea.
|
||||||
|
* The key content is stored in the database, not as a file on disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
|
||||||
|
class SshKeyField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'SshKey';
|
||||||
|
|
||||||
|
protected function getInput(): string
|
||||||
|
{
|
||||||
|
$value = $this->value ?? '';
|
||||||
|
$id = $this->id;
|
||||||
|
$name = $this->name;
|
||||||
|
|
||||||
|
$decoded = !empty($value) ? (base64_decode($value, true) ?: '') : '';
|
||||||
|
$hasKey = !empty($value) && ($value === '__KEEP_EXISTING__'
|
||||||
|
|| str_contains($value, 'PRIVATE KEY')
|
||||||
|
|| str_contains($decoded, 'PRIVATE KEY'));
|
||||||
|
|
||||||
|
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<span class="badge bg-success me-2">'
|
||||||
|
. '<span class="icon-lock" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File upload button */
|
||||||
|
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
|
||||||
|
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
|
||||||
|
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
|
||||||
|
$html .= '</label>';
|
||||||
|
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
|
||||||
|
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
|
||||||
|
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
|
||||||
|
|
||||||
|
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
|
||||||
|
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
|
||||||
|
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
|
||||||
|
. '<span class="icon-times" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
|
||||||
|
. '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden field — key data is NEVER rendered as visible text.
|
||||||
|
On existing keys, we submit a sentinel value to preserve the DB value
|
||||||
|
unless a new file is uploaded or clear is clicked. */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="__KEEP_EXISTING__">';
|
||||||
|
} else {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="">';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= $this->getScript();
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getScript(): string
|
||||||
|
{
|
||||||
|
return <<<'JS'
|
||||||
|
<script>
|
||||||
|
function mokoSshKeyFileSelected(fieldId, input) {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
var file = input.files[0];
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
/* Base64 encode the key before storing in the hidden field */
|
||||||
|
var content = e.target.result;
|
||||||
|
var encoded = btoa(content);
|
||||||
|
document.getElementById(fieldId).value = encoded;
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = file.name + ' uploaded';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mokoSshKeyClear(fieldId) {
|
||||||
|
document.getElementById(fieldId).value = '';
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = 'Key removed';
|
||||||
|
var fileInput = document.getElementById(fieldId + '-file');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,7 +294,7 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
->select($db->quoteName(['id', 'title', 'backup_type']))
|
->select($db->quoteName(['id', 'title', 'backup_type']))
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||||
->where($db->quoteName('published') . ' = 1')
|
->where($db->quoteName('published') . ' = 1')
|
||||||
->order($db->quoteName('ordering') . ' ASC');
|
->order($db->quoteName('id') . ' ASC');
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
|
|
||||||
return $db->loadObjectList() ?: [];
|
return $db->loadObjectList() ?: [];
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
|
|||||||
$query->select('a.*')
|
$query->select('a.*')
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
||||||
|
|
||||||
|
// Subquery: count of backup records per profile
|
||||||
|
$subQuery = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||||
|
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
|
||||||
|
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
|
||||||
|
|
||||||
$published = $this->getState('filter.published');
|
$published = $this->getState('filter.published');
|
||||||
|
|
||||||
if (is_numeric($published)) {
|
if (is_numeric($published)) {
|
||||||
@@ -53,14 +60,14 @@ class ProfilesModel extends ListModel
|
|||||||
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
$orderCol = $this->state->get('list.ordering', 'a.id');
|
||||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
protected function populateState($ordering = 'a.id', $direction = 'ASC'): void
|
||||||
{
|
{
|
||||||
parent::populateState($ordering, $direction);
|
parent::populateState($ordering, $direction);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\AdminModel;
|
||||||
|
|
||||||
|
class RemoteModel extends AdminModel
|
||||||
|
{
|
||||||
|
public function getForm($data = [], $loadData = true)
|
||||||
|
{
|
||||||
|
$form = $this->loadForm(
|
||||||
|
'com_mokosuitebackup.remote',
|
||||||
|
'remote',
|
||||||
|
['control' => 'jform', 'load_data' => $loadData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $form ?: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadFormData(): object
|
||||||
|
{
|
||||||
|
$data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.remote.data', []);
|
||||||
|
|
||||||
|
if (empty($data)) {
|
||||||
|
$data = $this->getItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($data) ? (object) $data : $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTable($name = 'Remote', $prefix = 'Administrator', $options = [])
|
||||||
|
{
|
||||||
|
return parent::getTable($name, $prefix, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all enabled remotes for a given profile.
|
||||||
|
*
|
||||||
|
* @param int $profileId The profile ID
|
||||||
|
*
|
||||||
|
* @return array Array of remote objects
|
||||||
|
*/
|
||||||
|
public function getEnabledByProfile(int $profileId): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$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() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\MVC\Model\ListModel;
|
||||||
|
use Joomla\Database\QueryInterface;
|
||||||
|
|
||||||
|
class RemotesModel extends ListModel
|
||||||
|
{
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
if (empty($config['filter_fields'])) {
|
||||||
|
$config['filter_fields'] = [
|
||||||
|
'id', 'a.id',
|
||||||
|
'profile_id', 'a.profile_id',
|
||||||
|
'title', 'a.title',
|
||||||
|
'type', 'a.type',
|
||||||
|
'enabled', 'a.enabled',
|
||||||
|
'ordering', 'a.ordering',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getListQuery(): QueryInterface
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
|
||||||
|
$query->select('a.*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes', 'a'));
|
||||||
|
|
||||||
|
// Join profile title
|
||||||
|
$query->select($db->quoteName('p.title', 'profile_title'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = a.profile_id');
|
||||||
|
|
||||||
|
// Filter by profile
|
||||||
|
$profileId = $this->getState('filter.profile_id');
|
||||||
|
|
||||||
|
if (is_numeric($profileId)) {
|
||||||
|
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type
|
||||||
|
$type = $this->getState('filter.type');
|
||||||
|
|
||||||
|
if (!empty($type)) {
|
||||||
|
$query->where($db->quoteName('a.type') . ' = ' . $db->quote($type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by enabled
|
||||||
|
$enabled = $this->getState('filter.enabled');
|
||||||
|
|
||||||
|
if (is_numeric($enabled)) {
|
||||||
|
$query->where($db->quoteName('a.enabled') . ' = ' . (int) $enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
$search = $this->getState('filter.search');
|
||||||
|
|
||||||
|
if (!empty($search)) {
|
||||||
|
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
|
||||||
|
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
||||||
|
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||||
|
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
||||||
|
{
|
||||||
|
parent::populateState($ordering, $direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,23 @@ class ProfileTable extends Table
|
|||||||
|
|
||||||
public function store($updateNulls = true): bool
|
public function store($updateNulls = true): bool
|
||||||
{
|
{
|
||||||
|
/* Handle SSH key sentinel — when __KEEP_EXISTING__ is submitted,
|
||||||
|
preserve the current DB value instead of overwriting with the sentinel.
|
||||||
|
This prevents the key from being exposed in the form HTML. */
|
||||||
|
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
|
||||||
|
if ($this->id) {
|
||||||
|
$db = $this->getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('sftp_key_data'))
|
||||||
|
->from($db->quoteName($this->_tbl))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $this->id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->sftp_key_data = $db->loadResult() ?: '';
|
||||||
|
} else {
|
||||||
|
$this->sftp_key_data = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = parent::store($updateNulls);
|
$result = parent::store($updateNulls);
|
||||||
|
|
||||||
if ($result && !empty($this->backup_dir)) {
|
if ($result && !empty($this->backup_dir)) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?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\Table;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Table\Table;
|
||||||
|
use Joomla\Database\DatabaseDriver;
|
||||||
|
|
||||||
|
class RemoteTable extends Table
|
||||||
|
{
|
||||||
|
public function __construct(DatabaseDriver $db)
|
||||||
|
{
|
||||||
|
parent::__construct('#__mokosuitebackup_remotes', 'id', $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->profile_id)) {
|
||||||
|
$this->setError('Profile ID is required.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validTypes = ['sftp', 's3', 'google_drive', 'ftp'];
|
||||||
|
|
||||||
|
if (empty($this->type) || !\in_array($this->type, $validTypes, true)) {
|
||||||
|
$this->setError('Invalid remote type. Must be one of: ' . implode(', ', $validTypes));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->title)) {
|
||||||
|
$this->title = ucfirst(str_replace('_', ' ', $this->type)) . ' Remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure params is valid JSON
|
||||||
|
if (!empty($this->params) && \is_string($this->params)) {
|
||||||
|
$decoded = json_decode($this->params);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$this->setError('Remote params must be valid JSON.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
|
||||||
|
$this->created = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->modified = $now;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the params as a decoded object.
|
||||||
|
*
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function getParams(): object
|
||||||
|
{
|
||||||
|
if (empty($this->params)) {
|
||||||
|
return (object) [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($this->params);
|
||||||
|
|
||||||
|
return \is_object($decoded) ? $decoded : (object) [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set params from an array or object, encoding to JSON.
|
||||||
|
*
|
||||||
|
* @param array|object $params The parameters to encode
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setParams(array|object $params): void
|
||||||
|
{
|
||||||
|
$this->params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -272,6 +272,6 @@ HTACCESS;
|
|||||||
*/
|
*/
|
||||||
public static function logPathFromArchive(string $archivePath): string
|
public static function logPathFromArchive(string $archivePath): string
|
||||||
{
|
{
|
||||||
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
return preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\View\Backup;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\CMS\Toolbar\Toolbar;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
@@ -34,6 +38,24 @@ class HtmlView extends BaseHtmlView
|
|||||||
protected function addToolbar(): void
|
protected function addToolbar(): void
|
||||||
{
|
{
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database');
|
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database');
|
||||||
|
|
||||||
|
$user = Factory::getApplication()->getIdentity();
|
||||||
|
|
||||||
|
if ($this->item->status === 'complete'
|
||||||
|
&& !empty($this->item->filesexist)
|
||||||
|
&& $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')
|
||||||
|
) {
|
||||||
|
$toolbar = Toolbar::getInstance();
|
||||||
|
$downloadUrl = Route::_(
|
||||||
|
'index.php?option=com_mokosuitebackup&task=backups.download&id='
|
||||||
|
. (int) $this->item->id . '&' . Session::getFormToken() . '=1'
|
||||||
|
);
|
||||||
|
$toolbar->linkButton('download', 'COM_MOKOJOOMBACKUP_DOWNLOAD')
|
||||||
|
->url($downloadUrl)
|
||||||
|
->icon('icon-download')
|
||||||
|
->buttonClass('btn btn-success');
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitebackup&view=backups');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitebackup&view=backups');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
protected $state;
|
protected $state;
|
||||||
public $filterForm;
|
public $filterForm;
|
||||||
public $activeFilters = [];
|
public $activeFilters = [];
|
||||||
public $profiles = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
@@ -35,16 +34,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
$this->filterForm = $this->get('FilterForm');
|
$this->filterForm = $this->get('FilterForm');
|
||||||
$this->activeFilters = $this->get('ActiveFilters');
|
$this->activeFilters = $this->get('ActiveFilters');
|
||||||
|
|
||||||
// Load published profiles for the backup selector
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName(['id', 'title', 'backup_type']))
|
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
||||||
->where($db->quoteName('published') . ' = 1')
|
|
||||||
->order($db->quoteName('ordering') . ' ASC');
|
|
||||||
$db->setQuery($query);
|
|
||||||
$this->profiles = $db->loadObjectList() ?: [];
|
|
||||||
|
|
||||||
$this->checkUpdateSite();
|
$this->checkUpdateSite();
|
||||||
$this->addToolbar();
|
$this->addToolbar();
|
||||||
|
|
||||||
@@ -112,17 +101,15 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database');
|
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database');
|
||||||
|
|
||||||
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
||||||
ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
if ($user->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
|
||||||
|
|
||||||
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
|
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +117,10 @@ class HtmlView extends BaseHtmlView
|
|||||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::preferences('com_mokosuitebackup');
|
ToolbarHelper::preferences('com_mokosuitebackup');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\CMS\Toolbar\Toolbar;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
@@ -48,6 +51,17 @@ class HtmlView extends BaseHtmlView
|
|||||||
ToolbarHelper::save('profile.save');
|
ToolbarHelper::save('profile.save');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$isNew) {
|
||||||
|
$toolbar = Toolbar::getInstance();
|
||||||
|
$profileId = (int) $this->item->id;
|
||||||
|
|
||||||
|
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||||
|
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||||
|
->url($backupsUrl)
|
||||||
|
->icon('icon-database')
|
||||||
|
->buttonClass('btn btn-info');
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,30 +31,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div id="j-main-container" class="j-main-container">
|
<div id="j-main-container" class="j-main-container">
|
||||||
<!-- Profile selector for Backup Now -->
|
|
||||||
<?php $canRun = $user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup'); ?>
|
|
||||||
<?php if (!empty($this->profiles) && $canRun) : ?>
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-body d-flex align-items-center gap-3">
|
|
||||||
<label for="mb-profile-select" class="form-label mb-0 fw-bold">
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_BACKUP_PROFILE'); ?>:
|
|
||||||
</label>
|
|
||||||
<select id="mb-profile-select" class="form-select" style="max-width:300px;">
|
|
||||||
<?php foreach ($this->profiles as $profile) : ?>
|
|
||||||
<option value="<?php echo (int) $profile->id; ?>">
|
|
||||||
<?php echo $this->escape($profile->title); ?>
|
|
||||||
(<?php echo $this->escape($profile->backup_type); ?>)
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="window.mokosuitebackupStart()">
|
|
||||||
<span class="icon-download" aria-hidden="true"></span>
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
||||||
|
|
||||||
<?php if (empty($this->items)) : ?>
|
<?php if (empty($this->items)) : ?>
|
||||||
@@ -88,9 +64,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<th scope="col" class="w-10">
|
<th scope="col" class="w-10">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="w-5">
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="w-5">
|
<th scope="col" class="w-5">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
@@ -111,7 +84,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=profile.edit&id=' . (int) $item->profile_id); ?>">
|
||||||
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php
|
<?php
|
||||||
@@ -139,35 +114,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<td>
|
<td>
|
||||||
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-flex gap-1">
|
|
||||||
<?php if ($item->status === 'complete' && $item->filesexist && $canDownload) : ?>
|
|
||||||
<?php
|
|
||||||
$isWebAccessible = !empty($item->absolute_path)
|
|
||||||
&& strpos(realpath($item->absolute_path) ?: $item->absolute_path, realpath(JPATH_ROOT) ?: JPATH_ROOT) === 0;
|
|
||||||
?>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.download&id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
|
||||||
class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOJOOMBACKUP_DOWNLOAD'); ?>">
|
|
||||||
<span class="icon-download"></span>
|
|
||||||
</a>
|
|
||||||
<?php if ($isWebAccessible) : ?>
|
|
||||||
<span class="badge bg-warning text-dark" title="<?php echo Text::_('COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING'); ?>">
|
|
||||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-info mb-browse-archive"
|
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
|
||||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>">
|
|
||||||
<span class="icon-folder-open"></span>
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
|
||||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
|
|
||||||
<span class="icon-file-alt"></span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<?php echo (int) $item->id; ?>
|
<?php echo (int) $item->id; ?>
|
||||||
</td>
|
</td>
|
||||||
@@ -188,14 +134,24 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Stepped Backup Modal (for shared hosting) -->
|
<!-- Stepped Backup Modal (for shared hosting) -->
|
||||||
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div class="modal-dialog">
|
||||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
<div class="modal-content">
|
||||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
<div class="modal-header">
|
||||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
<h5 class="modal-title" id="mb-modal-title">Backup in Progress</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||||
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
|
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||||
|
</div>
|
||||||
|
<div class="progress mb-2" style="height:24px;">
|
||||||
|
<div id="mb-progress-bar" class="progress-bar" role="progressbar" style="width:0%;">0%</div>
|
||||||
|
</div>
|
||||||
|
<p id="mb-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
|
||||||
|
<p id="mb-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="mb-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
|
||||||
<p id="mb-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,19 +160,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
||||||
|
|
||||||
// Override the toolbar "Backup Now" button to use stepped backup
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Find the backup toolbar button and override it
|
|
||||||
const toolbarBtn = document.querySelector('[onclick*="backups.start"], .button-download');
|
|
||||||
if (toolbarBtn) {
|
|
||||||
toolbarBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
startSteppedBackup();
|
|
||||||
return false;
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var backupRunning = false;
|
var backupRunning = false;
|
||||||
|
|
||||||
@@ -231,12 +174,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
|
|
||||||
function showModal() {
|
function showModal() {
|
||||||
backupRunning = true;
|
backupRunning = true;
|
||||||
document.getElementById('mokosuitebackup-modal').style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mokosuitebackup-modal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideModal() {
|
function hideModal() {
|
||||||
backupRunning = false;
|
backupRunning = false;
|
||||||
document.getElementById('mokosuitebackup-modal').style.display = 'none';
|
bootstrap.Modal.getInstance(document.getElementById('mokosuitebackup-modal'))?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress(progress, message, phase) {
|
function updateProgress(progress, message, phase) {
|
||||||
@@ -340,31 +283,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
document.getElementById('mb-restore-record-id').value = checked[0].value;
|
document.getElementById('mb-restore-record-id').value = checked[0].value;
|
||||||
document.getElementById('mb-restore-modal').style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-modal')).show();
|
||||||
return false;
|
return false;
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close restore modal
|
// Close restore modal handled by Bootstrap data-bs-dismiss
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') {
|
|
||||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// AJAX stepped restore
|
// AJAX stepped restore
|
||||||
var restoreRunning = false;
|
var restoreRunning = false;
|
||||||
|
|
||||||
function showRestoreProgress() {
|
function showRestoreProgress() {
|
||||||
restoreRunning = true;
|
restoreRunning = true;
|
||||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
bootstrap.Modal.getInstance(document.getElementById('mb-restore-modal'))?.hide();
|
||||||
document.getElementById('mb-restore-progress-modal').style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-progress-modal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideRestoreProgress() {
|
function hideRestoreProgress() {
|
||||||
restoreRunning = false;
|
restoreRunning = false;
|
||||||
document.getElementById('mb-restore-progress-modal').style.display = 'none';
|
bootstrap.Modal.getInstance(document.getElementById('mb-restore-progress-modal'))?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRestoreProgress(progress, message, phase) {
|
function updateRestoreProgress(progress, message, phase) {
|
||||||
@@ -453,145 +391,20 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// View Log modal handler
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest('.mb-view-log');
|
|
||||||
if (!btn) return;
|
|
||||||
e.preventDefault();
|
|
||||||
var recordId = btn.getAttribute('data-id');
|
|
||||||
var modal = document.getElementById('mb-log-modal');
|
|
||||||
var body = document.getElementById('mb-log-body');
|
|
||||||
body.textContent = 'Loading...';
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
var form = new URLSearchParams();
|
|
||||||
form.append('task', 'ajax.viewLog');
|
|
||||||
form.append('id', recordId);
|
|
||||||
form.append(TOKEN_NAME, '1');
|
|
||||||
|
|
||||||
fetch(AJAX_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (data.error) {
|
|
||||||
body.textContent = data.message || 'Error loading log';
|
|
||||||
} else {
|
|
||||||
body.textContent = data.log;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
body.textContent = 'Error: ' + err.message;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.id === 'mb-log-modal' || e.target.classList.contains('mb-log-close')) {
|
|
||||||
document.getElementById('mb-log-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Browse Archive modal handler
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
var units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
var i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
if (i >= units.length) i = units.length - 1;
|
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function browseSetMessage(tbody, message, cssClass) {
|
|
||||||
tbody.textContent = '';
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var td = document.createElement('td');
|
|
||||||
td.setAttribute('colspan', '3');
|
|
||||||
td.className = cssClass || 'text-center';
|
|
||||||
td.textContent = message;
|
|
||||||
tr.appendChild(td);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function browseAddFileRow(tbody, file) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
|
|
||||||
var tdName = document.createElement('td');
|
|
||||||
tdName.style.wordBreak = 'break-all';
|
|
||||||
tdName.style.fontSize = '0.85rem';
|
|
||||||
var code = document.createElement('code');
|
|
||||||
code.textContent = file.name;
|
|
||||||
tdName.appendChild(code);
|
|
||||||
tr.appendChild(tdName);
|
|
||||||
|
|
||||||
var tdSize = document.createElement('td');
|
|
||||||
tdSize.className = 'text-end text-nowrap';
|
|
||||||
tdSize.textContent = formatFileSize(file.size);
|
|
||||||
tr.appendChild(tdSize);
|
|
||||||
|
|
||||||
var tdComp = document.createElement('td');
|
|
||||||
tdComp.className = 'text-end text-nowrap';
|
|
||||||
tdComp.textContent = formatFileSize(file.compressed_size);
|
|
||||||
tr.appendChild(tdComp);
|
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest('.mb-browse-archive');
|
|
||||||
if (!btn) return;
|
|
||||||
e.preventDefault();
|
|
||||||
var recordId = btn.getAttribute('data-id');
|
|
||||||
var modal = document.getElementById('mb-browse-modal');
|
|
||||||
var tbody = document.getElementById('mb-browse-tbody');
|
|
||||||
var summary = document.getElementById('mb-browse-summary');
|
|
||||||
browseSetMessage(tbody, 'Loading...');
|
|
||||||
summary.textContent = '';
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
postAjax({ task: 'ajax.browseArchive', id: recordId })
|
|
||||||
.then(function(data) {
|
|
||||||
if (data.error) {
|
|
||||||
browseSetMessage(tbody, data.message || 'Error', 'text-danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.textContent = '';
|
|
||||||
if (data.files.length === 0) {
|
|
||||||
browseSetMessage(tbody, 'Archive is empty', 'text-center text-muted');
|
|
||||||
} else {
|
|
||||||
for (var i = 0; i < data.files.length; i++) {
|
|
||||||
browseAddFileRow(tbody, data.files[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
|
|
||||||
if (data.truncated) {
|
|
||||||
text += ' (showing first ' + data.files.length + ')';
|
|
||||||
}
|
|
||||||
summary.textContent = text;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
browseSetMessage(tbody, 'Error: ' + err.message, 'text-danger');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.id === 'mb-browse-modal' || e.target.classList.contains('mb-browse-close')) {
|
|
||||||
document.getElementById('mb-browse-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Restore Confirmation Modal -->
|
<!-- Restore Confirmation Modal -->
|
||||||
<div id="mb-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-restore-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div class="modal-dialog">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<div class="modal-content">
|
||||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h4>
|
<div class="modal-header">
|
||||||
<button type="button" class="btn-close mb-restore-close" aria-label="Close"></button>
|
<h5 class="modal-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
||||||
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
||||||
<div style="padding:1.5rem;">
|
<div class="modal-body">
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
||||||
@@ -625,8 +438,8 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary mb-restore-close"><?php echo Text::_('JCANCEL'); ?></button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
<span class="icon-upload" aria-hidden="true"></span>
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
||||||
@@ -638,71 +451,78 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Restore Progress Modal -->
|
<!-- Restore Progress Modal -->
|
||||||
<div id="mb-restore-progress-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div class="modal-dialog">
|
||||||
<h3 id="mb-restore-title" style="margin:0 0 1rem;">Restore in Progress</h3>
|
<div class="modal-content">
|
||||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
<div class="modal-header">
|
||||||
<div id="mb-restore-progress-bar" style="height:100%; background:#dc3545; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
<h5 class="modal-title" id="mb-restore-title">Restore in Progress</h5>
|
||||||
</div>
|
</div>
|
||||||
<p id="mb-restore-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
<div class="modal-body">
|
||||||
<p id="mb-restore-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
|
<div class="progress mb-2" style="height:24px;">
|
||||||
|
<div id="mb-restore-progress-bar" class="progress-bar bg-danger" role="progressbar" style="width:0%;">0%</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="mb-restore-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
|
||||||
|
<p id="mb-restore-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Viewer Modal -->
|
|
||||||
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
|
||||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
|
||||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
|
|
||||||
<button type="button" class="btn-close mb-log-close" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
|
||||||
<pre id="mb-log-body" style="white-space:pre-wrap; word-break:break-word; font-size:0.85rem; margin:0; background:#f8f9fa; padding:1rem; border-radius:4px;"></pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Archive Browser Modal -->
|
|
||||||
<div id="mb-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<!-- Purge Backups Modal -->
|
||||||
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<?php if ($canDelete) : ?>
|
||||||
<h4 style="margin:0;">
|
<div class="modal fade" id="mb-purge-modal" tabindex="-1" aria-hidden="true">
|
||||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
<div class="modal-dialog">
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
|
<div class="modal-content">
|
||||||
</h4>
|
<div class="modal-header">
|
||||||
<button type="button" class="btn-close mb-browse-close" aria-label="Close"></button>
|
<h5 class="modal-title">
|
||||||
|
<span class="icon-trash" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0.75rem 1.5rem; border-bottom:1px solid #dee2e6; background:#f8f9fa;">
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
|
||||||
<small id="mb-browse-summary" class="text-muted"></small>
|
<div class="modal-body">
|
||||||
|
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
|
||||||
|
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0; overflow-y:auto; flex:1;">
|
<div id="mb-purge-count-wrapper" style="display:none;">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<div id="mb-purge-none-wrapper" style="display:none;">
|
||||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
|
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
|
||||||
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
|
</div>
|
||||||
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
|
</div>
|
||||||
</tr>
|
<div class="modal-footer">
|
||||||
</thead>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
<tbody id="mb-browse-tbody">
|
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
|
||||||
</tbody>
|
<span class="icon-trash" aria-hidden="true"></span>
|
||||||
</table>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Backup Comparison Modal -->
|
<!-- Backup Comparison Modal -->
|
||||||
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-compare-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
|
<div class="modal-dialog modal-lg">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<div class="modal-content">
|
||||||
<h4 style="margin:0;">
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
<span class="icon-copy" aria-hidden="true"></span>
|
<span class="icon-copy" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
||||||
</h4>
|
</h5>
|
||||||
<button type="button" class="btn-close mb-compare-close" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
|
||||||
<div id="mb-compare-loading" style="text-align:center; padding:2rem;">
|
<div id="mb-compare-loading" class="text-center py-4">
|
||||||
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,6 +541,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -764,7 +585,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
var table = document.getElementById('mb-compare-table');
|
var table = document.getElementById('mb-compare-table');
|
||||||
var body = document.getElementById('mb-compare-body');
|
var body = document.getElementById('mb-compare-body');
|
||||||
|
|
||||||
modal.style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(modal).show();
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
errorEl.style.display = 'none';
|
errorEl.style.display = 'none';
|
||||||
table.style.display = 'none';
|
table.style.display = 'none';
|
||||||
@@ -831,12 +652,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close compare modal
|
// Compare modal close handled by Bootstrap data-bs-dismiss
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.id === 'mb-compare-modal' || e.target.classList.contains('mb-compare-close')) {
|
|
||||||
document.getElementById('mb-compare-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Intercept Compare toolbar button
|
// Intercept Compare toolbar button
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -859,3 +675,109 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<?php if ($canDelete) : ?>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
|
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||||
|
var purgeCountTimer = null;
|
||||||
|
|
||||||
|
// Intercept Purge toolbar button to show the modal
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
|
||||||
|
if (purgeBtn) {
|
||||||
|
purgeBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Reset modal state
|
||||||
|
document.getElementById('mb-purge-date').value = '';
|
||||||
|
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
|
||||||
|
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
|
||||||
|
document.getElementById('mb-purge-submit').disabled = true;
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
|
||||||
|
return false;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date change triggers count lookup with debounce
|
||||||
|
var dateInput = document.getElementById('mb-purge-date');
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.addEventListener('change', function() {
|
||||||
|
if (purgeCountTimer) clearTimeout(purgeCountTimer);
|
||||||
|
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge modal close handled by Bootstrap data-bs-dismiss
|
||||||
|
|
||||||
|
// Confirm on submit
|
||||||
|
var purgeForm = document.getElementById('mb-purge-form');
|
||||||
|
if (purgeForm) {
|
||||||
|
purgeForm.addEventListener('submit', function(e) {
|
||||||
|
var msg = document.getElementById('mb-purge-count-msg').textContent;
|
||||||
|
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchPurgeCount() {
|
||||||
|
var dateVal = document.getElementById('mb-purge-date').value;
|
||||||
|
var countWrapper = document.getElementById('mb-purge-count-wrapper');
|
||||||
|
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
|
||||||
|
var countMsg = document.getElementById('mb-purge-count-msg');
|
||||||
|
var submitBtn = document.getElementById('mb-purge-submit');
|
||||||
|
|
||||||
|
if (!dateVal) {
|
||||||
|
countWrapper.style.display = 'none';
|
||||||
|
noneWrapper.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
|
||||||
|
countWrapper.style.display = 'block';
|
||||||
|
noneWrapper.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
var form = new URLSearchParams();
|
||||||
|
form.append('task', 'ajax.countPurge');
|
||||||
|
form.append('date', dateVal);
|
||||||
|
form.append(PURGE_TOKEN, '1');
|
||||||
|
|
||||||
|
fetch(PURGE_AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
countMsg.textContent = data.message || 'Error';
|
||||||
|
countWrapper.style.display = 'block';
|
||||||
|
noneWrapper.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
} else if (data.count === 0) {
|
||||||
|
countWrapper.style.display = 'none';
|
||||||
|
noneWrapper.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
|
||||||
|
countMsg.textContent = text.replace('%d', data.count);
|
||||||
|
countWrapper.style.display = 'block';
|
||||||
|
noneWrapper.style.display = 'none';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
countMsg.textContent = 'Error: ' + err.message;
|
||||||
|
countWrapper.style.display = 'block';
|
||||||
|
noneWrapper.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
|||||||
<select id="mb-profile-select" class="form-select mb-2">
|
<select id="mb-profile-select" class="form-select mb-2">
|
||||||
<?php foreach ($this->profiles as $profile) : ?>
|
<?php foreach ($this->profiles as $profile) : ?>
|
||||||
<option value="<?php echo (int) $profile->id; ?>">
|
<option value="<?php echo (int) $profile->id; ?>">
|
||||||
|
#<?php echo (int) $profile->id; ?> —
|
||||||
<?php echo $this->escape($profile->title); ?>
|
<?php echo $this->escape($profile->title); ?>
|
||||||
(<?php echo $this->escape($profile->backup_type); ?>)
|
(<?php echo $this->escape($profile->backup_type); ?>)
|
||||||
</option>
|
</option>
|
||||||
@@ -305,6 +306,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
|||||||
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||||
|
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||||
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
|
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||||
|
</div>
|
||||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\HTML\HTMLHelper;
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
HTMLHelper::_('behavior.formvalidator');
|
HTMLHelper::_('behavior.formvalidator');
|
||||||
HTMLHelper::_('behavior.keepalive');
|
HTMLHelper::_('behavior.keepalive');
|
||||||
|
|
||||||
|
$profileId = (int) $this->item->id;
|
||||||
|
$token = Session::getFormToken();
|
||||||
?>
|
?>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . (int) $this->item->id); ?>"
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . $profileId); ?>"
|
||||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
method="post" name="adminForm" id="adminForm" class="form-validate">
|
||||||
|
|
||||||
<div class="main-card">
|
<div class="main-card">
|
||||||
@@ -60,13 +64,55 @@ HTMLHelper::_('behavior.keepalive');
|
|||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-9">
|
<div class="col-lg-12">
|
||||||
|
<?php // ---- Remote Destinations (multi-remote) ---- ?>
|
||||||
|
<?php if ($profileId): ?>
|
||||||
|
<div id="mokoRemoteDestinations" class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS'); ?></h3>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" id="btnAddRemote">
|
||||||
|
<span class="icon-plus" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table" id="remoteDestTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th style="width:120px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||||
|
<th style="width:100px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS'); ?></th>
|
||||||
|
<th style="width:160px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="remoteDestBody">
|
||||||
|
<tr id="remoteDestLoading">
|
||||||
|
<td colspan="4" class="text-center text-muted">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-muted small" id="remoteDestEmpty" style="display:none;">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php // ---- Legacy single-remote fields ---- ?>
|
||||||
|
<div id="legacyRemoteFields">
|
||||||
|
<div class="alert alert-info small" id="legacyRemoteNote" style="display:none;">
|
||||||
|
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE'); ?>
|
||||||
|
</div>
|
||||||
<?php echo $this->form->renderFieldset('remote'); ?>
|
<?php echo $this->form->renderFieldset('remote'); ?>
|
||||||
<?php echo $this->form->renderFieldset('ftp'); ?>
|
<?php echo $this->form->renderFieldset('ftp'); ?>
|
||||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
||||||
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
|
|||||||
<input type="hidden" name="task" value="">
|
<input type="hidden" name="task" value="">
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php // ---- Remote Destination Add/Edit Modal ---- ?>
|
||||||
|
<?php if ($profileId): ?>
|
||||||
|
<div class="modal fade" id="remoteModal" tabindex="-1" aria-labelledby="remoteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="remoteModalLabel"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php echo Text::_('JCLOSE'); ?>"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="remoteEditId" value="0">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteTitle" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteTitle" maxlength="255" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="remoteType" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></label>
|
||||||
|
<select class="form-select" id="remoteType">
|
||||||
|
<option value="sftp"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP'); ?></option>
|
||||||
|
<option value="s3"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3'); ?></option>
|
||||||
|
<option value="google_drive"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE'); ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ENABLED'); ?></label>
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remoteEnabled" checked>
|
||||||
|
<label class="form-check-label" for="remoteEnabled"><?php echo Text::_('JYES'); ?></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL'); ?></label>
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remoteKeepLocal" checked>
|
||||||
|
<label class="form-check-label" for="remoteKeepLocal"><?php echo Text::_('JYES'); ?></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<?php // SFTP fields ?>
|
||||||
|
<div id="remoteFields_sftp" class="remote-type-fields">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="remoteCfg_sftp_host" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_host" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="remoteCfg_sftp_port" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT'); ?></label>
|
||||||
|
<input type="number" class="form-control" id="remoteCfg_sftp_port" value="22" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_username" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_username" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_auth_type" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE'); ?></label>
|
||||||
|
<select class="form-select" id="remoteCfg_sftp_auth_type">
|
||||||
|
<option value="key"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY'); ?></option>
|
||||||
|
<option value="password"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD'); ?></option>
|
||||||
|
<option value="key_passphrase"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE'); ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpPasswordWrap">
|
||||||
|
<label for="remoteCfg_sftp_password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_sftp_password" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpKeyWrap">
|
||||||
|
<label for="remoteCfg_sftp_key_data" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY'); ?></label>
|
||||||
|
<textarea class="form-control" id="remoteCfg_sftp_key_data" rows="4" placeholder="Paste SSH private key or leave as-is to keep existing"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpPassphraseWrap">
|
||||||
|
<label for="remoteCfg_sftp_passphrase" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_sftp_passphrase" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_path" value="/backups" maxlength="512">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php // S3 fields ?>
|
||||||
|
<div id="remoteFields_s3" class="remote-type-fields" style="display:none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_endpoint" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_endpoint" maxlength="512" placeholder="https://s3.amazonaws.com">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_region" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_REGION'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_region" value="us-east-1" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_access_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_access_key" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_secret_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_s3_secret_key" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_bucket" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_bucket" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_PATH'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_path" value="/backups" maxlength="512">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php // Google Drive fields ?>
|
||||||
|
<div id="remoteFields_google_drive" class="remote-type-fields" style="display:none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_client_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_client_id" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_client_secret" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_gdrive_client_secret" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_refresh_token" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_refresh_token" maxlength="512">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_folder_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_folder_id" maxlength="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btnSaveRemote">
|
||||||
|
<span class="icon-save" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('JAPPLY'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const profileId = <?php echo $profileId; ?>;
|
||||||
|
const token = '<?php echo $token; ?>';
|
||||||
|
|
||||||
|
if (!profileId) return;
|
||||||
|
|
||||||
|
const baseUrl = 'index.php?option=com_mokosuitebackup&task=ajax.';
|
||||||
|
const tbody = document.getElementById('remoteDestBody');
|
||||||
|
const emptyMsg = document.getElementById('remoteDestEmpty');
|
||||||
|
const loadingTr = document.getElementById('remoteDestLoading');
|
||||||
|
const legacy = document.getElementById('legacyRemoteFields');
|
||||||
|
const legacyNote = document.getElementById('legacyRemoteNote');
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('remoteModal'));
|
||||||
|
|
||||||
|
// Type badge colours
|
||||||
|
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
|
||||||
|
const typeLabel = {
|
||||||
|
sftp: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP', true); ?>',
|
||||||
|
s3: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3', true); ?>',
|
||||||
|
google_drive: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE', true); ?>'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config field mappings per type
|
||||||
|
const configFields = {
|
||||||
|
sftp: ['host','port','username','auth_type','password','key_data','passphrase','path'],
|
||||||
|
s3: ['endpoint','region','access_key','secret_key','bucket','path'],
|
||||||
|
google_drive: ['client_id','client_secret','refresh_token','folder_id']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefix mapping for config field IDs
|
||||||
|
const fieldPrefix = {sftp: 'sftp_', s3: 's3_', google_drive: 'gdrive_'};
|
||||||
|
|
||||||
|
let remotesData = [];
|
||||||
|
|
||||||
|
// ---- Load remotes ----
|
||||||
|
function loadRemotes() {
|
||||||
|
loadingTr.style.display = '';
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
|
||||||
|
fetch(baseUrl + 'listRemotes&profile_id=' + profileId + '&' + token + '=1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
loadingTr.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
showTableMessage(data.message, 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remotesData = data.items || [];
|
||||||
|
renderTable();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loadingTr.style.display = 'none';
|
||||||
|
showTableMessage('Failed to load remotes', 'text-danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
|
||||||
|
if (!remotesData.length) {
|
||||||
|
emptyMsg.style.display = '';
|
||||||
|
legacy.style.display = '';
|
||||||
|
legacyNote.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
legacy.style.display = 'none';
|
||||||
|
legacyNote.style.display = 'block';
|
||||||
|
|
||||||
|
remotesData.forEach(function(item) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// Title cell
|
||||||
|
const tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = item.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
// Type badge cell
|
||||||
|
const tdType = document.createElement('td');
|
||||||
|
const badgeSpan = document.createElement('span');
|
||||||
|
badgeSpan.className = 'badge ' + (typeBadge[item.type] || 'bg-secondary');
|
||||||
|
badgeSpan.textContent = typeLabel[item.type] || item.type;
|
||||||
|
tdType.appendChild(badgeSpan);
|
||||||
|
tr.appendChild(tdType);
|
||||||
|
|
||||||
|
// Enabled toggle cell
|
||||||
|
const tdEnabled = document.createElement('td');
|
||||||
|
const toggleSpan = document.createElement('span');
|
||||||
|
toggleSpan.className = 'badge ' + (item.enabled ? 'bg-success' : 'bg-secondary');
|
||||||
|
toggleSpan.style.cursor = 'pointer';
|
||||||
|
toggleSpan.setAttribute('data-toggle-id', item.id);
|
||||||
|
toggleSpan.textContent = item.enabled ? 'Enabled' : 'Disabled';
|
||||||
|
tdEnabled.appendChild(toggleSpan);
|
||||||
|
tr.appendChild(tdEnabled);
|
||||||
|
|
||||||
|
// Actions cell
|
||||||
|
const tdActions = document.createElement('td');
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'btn btn-sm btn-outline-primary me-1';
|
||||||
|
editBtn.setAttribute('data-edit-id', item.id);
|
||||||
|
editBtn.title = 'Edit';
|
||||||
|
const editIcon = document.createElement('span');
|
||||||
|
editIcon.className = 'icon-pencil';
|
||||||
|
editIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
editBtn.appendChild(editIcon);
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.type = 'button';
|
||||||
|
delBtn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
delBtn.setAttribute('data-delete-id', item.id);
|
||||||
|
delBtn.title = 'Delete';
|
||||||
|
const delIcon = document.createElement('span');
|
||||||
|
delIcon.className = 'icon-trash';
|
||||||
|
delIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
delBtn.appendChild(delIcon);
|
||||||
|
tdActions.appendChild(delBtn);
|
||||||
|
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTableMessage(message, cssClass) {
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', '4');
|
||||||
|
td.className = cssClass || '';
|
||||||
|
td.textContent = message;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toggle enabled ----
|
||||||
|
tbody.addEventListener('click', function(e) {
|
||||||
|
const toggle = e.target.closest('[data-toggle-id]');
|
||||||
|
if (toggle) {
|
||||||
|
const id = toggle.getAttribute('data-toggle-id');
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', id);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
|
||||||
|
fetch(baseUrl + 'toggleRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { if (!data.error) loadRemotes(); })
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBtn = e.target.closest('[data-edit-id]');
|
||||||
|
if (editBtn) {
|
||||||
|
openEdit(parseInt(editBtn.getAttribute('data-edit-id'), 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delBtn = e.target.closest('[data-delete-id]');
|
||||||
|
if (delBtn) {
|
||||||
|
if (!confirm('<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM', true); ?>')) return;
|
||||||
|
const id = delBtn.getAttribute('data-delete-id');
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', id);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
|
||||||
|
fetch(baseUrl + 'deleteRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { if (!data.error) loadRemotes(); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Add button ----
|
||||||
|
document.getElementById('btnAddRemote').addEventListener('click', function() {
|
||||||
|
openEdit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Open modal for add / edit ----
|
||||||
|
function openEdit(id) {
|
||||||
|
document.getElementById('remoteEditId').value = id;
|
||||||
|
document.getElementById('remoteTitle').value = '';
|
||||||
|
document.getElementById('remoteType').value = 'sftp';
|
||||||
|
document.getElementById('remoteEnabled').checked = true;
|
||||||
|
document.getElementById('remoteKeepLocal').checked = true;
|
||||||
|
|
||||||
|
// Clear all config fields
|
||||||
|
document.querySelectorAll('.remote-type-fields input, .remote-type-fields textarea, .remote-type-fields select').forEach(function(el) {
|
||||||
|
if (el.type === 'number') {
|
||||||
|
el.value = el.defaultValue || '';
|
||||||
|
} else if (el.tagName === 'SELECT') {
|
||||||
|
el.selectedIndex = 0;
|
||||||
|
} else {
|
||||||
|
el.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore defaults
|
||||||
|
const portField = document.getElementById('remoteCfg_sftp_port');
|
||||||
|
if (portField) portField.value = '22';
|
||||||
|
const s3Region = document.getElementById('remoteCfg_s3_region');
|
||||||
|
if (s3Region) s3Region.value = 'us-east-1';
|
||||||
|
const sftpPath = document.getElementById('remoteCfg_sftp_path');
|
||||||
|
if (sftpPath) sftpPath.value = '/backups';
|
||||||
|
const s3Path = document.getElementById('remoteCfg_s3_path');
|
||||||
|
if (s3Path) s3Path.value = '/backups';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const item = remotesData.find(r => r.id === id);
|
||||||
|
if (item) {
|
||||||
|
document.getElementById('remoteTitle').value = item.title;
|
||||||
|
document.getElementById('remoteType').value = item.type;
|
||||||
|
document.getElementById('remoteEnabled').checked = !!item.enabled;
|
||||||
|
document.getElementById('remoteKeepLocal').checked = !!item.keep_local;
|
||||||
|
|
||||||
|
// Populate config fields
|
||||||
|
const prefix = fieldPrefix[item.type] || '';
|
||||||
|
const fields = configFields[item.type] || [];
|
||||||
|
fields.forEach(function(f) {
|
||||||
|
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||||
|
if (el && item.config && item.config[f] !== undefined) {
|
||||||
|
el.value = item.config[f];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('remoteModalLabel').textContent =
|
||||||
|
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_EDIT', true); ?>';
|
||||||
|
} else {
|
||||||
|
document.getElementById('remoteModalLabel').textContent =
|
||||||
|
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD', true); ?>';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTypeFields();
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Type selector toggles field visibility ----
|
||||||
|
document.getElementById('remoteType').addEventListener('change', updateTypeFields);
|
||||||
|
|
||||||
|
function updateTypeFields() {
|
||||||
|
const type = document.getElementById('remoteType').value;
|
||||||
|
document.querySelectorAll('.remote-type-fields').forEach(function(el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const target = document.getElementById('remoteFields_' + type);
|
||||||
|
if (target) target.style.display = '';
|
||||||
|
|
||||||
|
// SFTP auth_type sub-fields
|
||||||
|
if (type === 'sftp') {
|
||||||
|
updateSftpAuthFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sftpAuthType = document.getElementById('remoteCfg_sftp_auth_type');
|
||||||
|
if (sftpAuthType) {
|
||||||
|
sftpAuthType.addEventListener('change', updateSftpAuthFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSftpAuthFields() {
|
||||||
|
const auth = document.getElementById('remoteCfg_sftp_auth_type').value;
|
||||||
|
document.getElementById('remoteSftpPasswordWrap').style.display = (auth === 'password') ? '' : 'none';
|
||||||
|
document.getElementById('remoteSftpKeyWrap').style.display = (auth === 'key' || auth === 'key_passphrase') ? '' : 'none';
|
||||||
|
document.getElementById('remoteSftpPassphraseWrap').style.display = (auth === 'key_passphrase') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save remote ----
|
||||||
|
document.getElementById('btnSaveRemote').addEventListener('click', function() {
|
||||||
|
const type = document.getElementById('remoteType').value;
|
||||||
|
const title = document.getElementById('remoteTitle').value.trim();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
document.getElementById('remoteTitle').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config object from visible fields
|
||||||
|
const config = {};
|
||||||
|
const prefix = fieldPrefix[type] || '';
|
||||||
|
const fields = configFields[type] || [];
|
||||||
|
|
||||||
|
fields.forEach(function(f) {
|
||||||
|
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||||
|
if (el) {
|
||||||
|
config[f] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', document.getElementById('remoteEditId').value);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
body.set('remote_title', title);
|
||||||
|
body.set('remote_type', type);
|
||||||
|
body.set('remote_enabled', document.getElementById('remoteEnabled').checked ? '1' : '0');
|
||||||
|
body.set('remote_keep_local', document.getElementById('remoteKeepLocal').checked ? '1' : '0');
|
||||||
|
body.set('remote_config', JSON.stringify(config));
|
||||||
|
|
||||||
|
document.getElementById('btnSaveRemote').disabled = true;
|
||||||
|
|
||||||
|
fetch(baseUrl + 'saveRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('btnSaveRemote').disabled = false;
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
alert(data.message || 'Save failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.hide();
|
||||||
|
loadRemotes();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('btnSaveRemote').disabled = false;
|
||||||
|
alert('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadRemotes();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\Layout\LayoutHelper;
|
use Joomla\CMS\Layout\LayoutHelper;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
HTMLHelper::_('behavior.multiselect');
|
HTMLHelper::_('behavior.multiselect');
|
||||||
|
|
||||||
@@ -45,6 +46,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<th scope="col" class="w-10">
|
<th scope="col" class="w-10">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" class="w-5 text-center">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
|
||||||
|
</th>
|
||||||
<th scope="col" class="w-10">
|
<th scope="col" class="w-10">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
@@ -70,6 +74,13 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<td>
|
<td>
|
||||||
<?php echo $this->escape($item->backup_type); ?>
|
<?php echo $this->escape($item->backup_type); ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
|
||||||
|
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||||
|
<?php echo (int) $item->backup_count; ?>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -99,14 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||||
<?php if (in_array('articles', $types)) : ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||||
<span class="icon-search"></span>
|
<span class="icon-search"></span>
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||||
@@ -134,14 +132,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Create Snapshot Modal -->
|
<!-- Create Snapshot Modal -->
|
||||||
<div id="mb-snapshot-create-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-snapshot-create-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div class="modal-dialog">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<div class="modal-content">
|
||||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h4>
|
<div class="modal-header">
|
||||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
<h5 class="modal-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
||||||
<div style="padding:1.5rem;">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
||||||
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
||||||
@@ -171,8 +170,8 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon-camera" aria-hidden="true"></span>
|
<span class="icon-camera" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
||||||
@@ -182,17 +181,19 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Restore Snapshot Modal -->
|
<!-- Restore Snapshot Modal -->
|
||||||
<div id="mb-snapshot-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-snapshot-restore-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
<div class="modal-dialog">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<div class="modal-content">
|
||||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h4>
|
<div class="modal-header">
|
||||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
<h5 class="modal-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
||||||
<input type="hidden" name="id" id="mb-restore-id" value="">
|
<input type="hidden" name="id" id="mb-restore-id" value="">
|
||||||
<div style="padding:1.5rem;">
|
<div class="modal-body">
|
||||||
<p id="mb-restore-desc" class="fw-bold"></p>
|
<p id="mb-restore-desc" class="fw-bold"></p>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -215,7 +216,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
|
|
||||||
<div class="mb-3" id="mb-restore-types-container">
|
<div class="mb-3" id="mb-restore-types-container">
|
||||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
||||||
<!-- Populated by JS from data-types -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
||||||
@@ -223,8 +223,8 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
<span class="icon-upload" aria-hidden="true"></span>
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
||||||
@@ -234,23 +234,51 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Browse Snapshot Articles Modal -->
|
<!-- Browse Snapshot Detail Modal -->
|
||||||
<div id="mb-snapshot-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
<div class="modal fade" id="mb-snapshot-browse-modal" tabindex="-1" aria-hidden="true">
|
||||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
<div class="modal-dialog modal-xl">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
<div class="modal-content">
|
||||||
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
<div class="modal-header">
|
||||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
<h5 class="modal-title" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
|
||||||
<input type="hidden" name="id" id="mb-browse-id" value="">
|
<input type="hidden" name="id" id="mb-browse-id" value="">
|
||||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
<div class="modal-body" style="max-height:60vh; overflow-y:auto;">
|
||||||
<div id="mb-browse-loading" class="text-center py-4">
|
<div id="mb-browse-loading" class="text-center py-4">
|
||||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||||
</div>
|
</div>
|
||||||
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||||
<div id="mb-browse-content" style="display:none;">
|
<div id="mb-browse-content" style="display:none;">
|
||||||
|
|
||||||
|
<!-- Bootstrap tabs -->
|
||||||
|
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content pt-3" id="mb-browse-tabs-content">
|
||||||
|
|
||||||
|
<!-- Articles tab -->
|
||||||
|
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-check form-check-inline">
|
<label class="form-check form-check-inline">
|
||||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||||
@@ -271,9 +299,44 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<tbody id="mb-browse-tbody"></tbody>
|
<tbody id="mb-browse-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories tab -->
|
||||||
|
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-categories-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-categories-tbody"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
|
||||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
<!-- Modules tab -->
|
||||||
|
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-modules-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-modules-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
<button type="submit" class="btn btn-success" id="mb-browse-restore-btn" disabled>
|
<button type="submit" class="btn btn-success" id="mb-browse-restore-btn" disabled>
|
||||||
<span class="icon-upload" aria-hidden="true"></span>
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
|
||||||
@@ -283,6 +346,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -293,7 +357,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
createBtn.addEventListener('click', function(e) {
|
createBtn.addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.getElementById('mb-snapshot-create-modal').style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-snapshot-create-modal')).show();
|
||||||
return false;
|
return false;
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
@@ -344,7 +408,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
var label = document.createElement('label');
|
var label = document.createElement('label');
|
||||||
label.className = 'form-check-label';
|
label.className = 'form-check-label';
|
||||||
label.setAttribute('for', 'mb-rtype-' + type);
|
label.setAttribute('for', 'mb-rtype-' + type);
|
||||||
label.textContent = typeLabels[type] || type;
|
label.textContent = typeLabels[TYPE] || type;
|
||||||
|
|
||||||
div.appendChild(input);
|
div.appendChild(input);
|
||||||
div.appendChild(label);
|
div.appendChild(label);
|
||||||
@@ -354,7 +418,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
// Show/hide replace warning based on mode
|
// Show/hide replace warning based on mode
|
||||||
toggleReplaceWarning();
|
toggleReplaceWarning();
|
||||||
|
|
||||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'block';
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-snapshot-restore-modal')).show();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle warning when mode changes
|
// Toggle warning when mode changes
|
||||||
@@ -388,9 +452,16 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
document.getElementById('mb-browse-restore-btn').disabled = true;
|
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||||
document.getElementById('mb-browse-select-all').checked = false;
|
document.getElementById('mb-browse-select-all').checked = false;
|
||||||
|
|
||||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
// Reset to Articles tab
|
||||||
|
var firstTab = document.querySelector('#mb-tab-articles-btn');
|
||||||
|
if (firstTab && typeof bootstrap !== 'undefined') {
|
||||||
|
var tab = new bootstrap.Tab(firstTab);
|
||||||
|
tab.show();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch articles via AJAX
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-snapshot-browse-modal')).show();
|
||||||
|
|
||||||
|
// Fetch snapshot content via AJAX
|
||||||
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||||
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||||
|
|
||||||
@@ -405,13 +476,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tbody = document.getElementById('mb-browse-tbody');
|
|
||||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
|
||||||
|
|
||||||
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||||
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||||
|
|
||||||
data.articles.forEach(function(article) {
|
// --- Articles ---
|
||||||
|
var tbody = document.getElementById('mb-browse-tbody');
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
|
||||||
|
(data.articles || []).forEach(function(article) {
|
||||||
var tr = document.createElement('tr');
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
var tdCheck = document.createElement('td');
|
var tdCheck = document.createElement('td');
|
||||||
@@ -445,12 +517,84 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
|
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
|
||||||
|
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
|
||||||
|
|
||||||
|
// --- Categories ---
|
||||||
|
var catTbody = document.getElementById('mb-browse-categories-tbody');
|
||||||
|
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
|
||||||
|
|
||||||
|
(data.categories || []).forEach(function(cat) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = cat.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdExt = document.createElement('td');
|
||||||
|
tdExt.textContent = cat.extension;
|
||||||
|
tr.appendChild(tdExt);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
var tdLevel = document.createElement('td');
|
||||||
|
tdLevel.textContent = cat.level;
|
||||||
|
tr.appendChild(tdLevel);
|
||||||
|
|
||||||
|
catTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
|
||||||
|
|
||||||
|
// --- Modules ---
|
||||||
|
var modTbody = document.getElementById('mb-browse-modules-tbody');
|
||||||
|
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
|
||||||
|
|
||||||
|
(data.modules || []).forEach(function(mod) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = mod.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = mod.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdType = document.createElement('td');
|
||||||
|
tdType.textContent = mod.module;
|
||||||
|
tr.appendChild(tdType);
|
||||||
|
|
||||||
|
var tdPos = document.createElement('td');
|
||||||
|
tdPos.textContent = mod.position;
|
||||||
|
tr.appendChild(tdPos);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
modTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
|
||||||
|
|
||||||
document.getElementById('mb-browse-content').style.display = 'block';
|
document.getElementById('mb-browse-content').style.display = 'block';
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
document.getElementById('mb-browse-loading').style.display = 'none';
|
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||||
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
|
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
|
||||||
document.getElementById('mb-browse-error').style.display = 'block';
|
document.getElementById('mb-browse-error').style.display = 'block';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -478,16 +622,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modals
|
// Modal close handled by Bootstrap data-bs-dismiss
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.classList.contains('mb-modal-close') ||
|
|
||||||
e.target.id === 'mb-snapshot-create-modal' ||
|
|
||||||
e.target.id === 'mb-snapshot-restore-modal' ||
|
|
||||||
e.target.id === 'mb-snapshot-browse-modal') {
|
|
||||||
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
|
||||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
|
||||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
; MokoSuiteBackup — CPanel Module language file (en-GB)
|
||||||
|
; @package MokoSuiteBackup
|
||||||
|
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED="MokoSuiteBackup is not installed or is disabled."
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet."
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables"
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED="Next Scheduled"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_TOTAL="total"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_STREAK="streak"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D="failed (7d)"
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW="Backup Now"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS="Backup in Progress"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE="Backup Complete"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE="Do not navigate away or close this window while the backup is running."
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS="View Backups"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT="Create Snapshot"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES="View Profiles"
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS="Show Backup Now Buttons"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE="Show Next Scheduled"
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
; MokoSuiteBackup — CPanel Module system language file (en-GB)
|
||||||
|
; @package MokoSuiteBackup
|
||||||
|
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
; @license GPL-3.0-or-later
|
||||||
|
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
|
||||||
|
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage mod_mokosuitebackup_cpanel
|
||||||
|
* @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;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage mod_mokosuitebackup_cpanel
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
-->
|
||||||
|
<extension type="module" client="administrator" method="upgrade">
|
||||||
|
<name>mod_mokosuitebackup_cpanel</name>
|
||||||
|
<version>01.43.32</version>
|
||||||
|
<creationDate>2026-06-23</creationDate>
|
||||||
|
<author>Moko Consulting</author>
|
||||||
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<description>MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION</description>
|
||||||
|
|
||||||
|
<namespace path="src">Joomla\Module\MokoSuiteBackupCpanel</namespace>
|
||||||
|
|
||||||
|
<files>
|
||||||
|
<filename module="mod_mokosuitebackup_cpanel">mod_mokosuitebackup_cpanel.php</filename>
|
||||||
|
<folder>language</folder>
|
||||||
|
<folder>services</folder>
|
||||||
|
<folder>src</folder>
|
||||||
|
<folder>tmpl</folder>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<languages folder="language">
|
||||||
|
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.ini</language>
|
||||||
|
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.sys.ini</language>
|
||||||
|
</languages>
|
||||||
|
|
||||||
|
<config>
|
||||||
|
<fields name="params">
|
||||||
|
<fieldset name="basic">
|
||||||
|
<field
|
||||||
|
name="show_backup_buttons"
|
||||||
|
type="radio"
|
||||||
|
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS"
|
||||||
|
default="1"
|
||||||
|
class="btn-group btn-group-yesno"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="show_schedule"
|
||||||
|
type="radio"
|
||||||
|
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE"
|
||||||
|
default="1"
|
||||||
|
class="btn-group btn-group-yesno"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
</fieldset>
|
||||||
|
</fields>
|
||||||
|
</config>
|
||||||
|
</extension>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage mod_mokosuitebackup_cpanel
|
||||||
|
* @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\Service\Provider\HelperFactory;
|
||||||
|
use Joomla\CMS\Extension\Service\Provider\Module;
|
||||||
|
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
|
||||||
|
use Joomla\DI\Container;
|
||||||
|
use Joomla\DI\ServiceProviderInterface;
|
||||||
|
|
||||||
|
return new class () implements ServiceProviderInterface {
|
||||||
|
public function register(Container $container): void
|
||||||
|
{
|
||||||
|
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel'));
|
||||||
|
$container->registerServiceProvider(new HelperFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel\\Administrator\\Helper'));
|
||||||
|
$container->registerServiceProvider(new Module());
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage mod_mokosuitebackup_cpanel
|
||||||
|
* @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\Module\MokoSuiteBackupCpanel\Administrator\Dispatcher;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper;
|
||||||
|
|
||||||
|
class Dispatcher extends AbstractModuleDispatcher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns the layout data for the module template.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function getLayoutData(): array
|
||||||
|
{
|
||||||
|
$data = parent::getLayoutData();
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
|
||||||
|
// Status summary from the shared helper
|
||||||
|
$status = BackupStatusHelper::getStatusSummary();
|
||||||
|
|
||||||
|
// Published profiles for "Backup Now" buttons
|
||||||
|
$profiles = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName(['id', 'title', 'backup_type']))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||||
|
->where($db->quoteName('published') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
$profiles = $db->loadObjectList() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Component may not be installed yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next scheduled backup
|
||||||
|
$nextScheduled = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName(['t.next_execution', 't.title']))
|
||||||
|
->from($db->quoteName('#__scheduler_tasks', 't'))
|
||||||
|
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile'))
|
||||||
|
->where($db->quoteName('t.state') . ' = 1')
|
||||||
|
->order($db->quoteName('t.next_execution') . ' ASC');
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
$nextScheduled = $db->loadObject() ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Scheduler may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['status'] = $status;
|
||||||
|
$data['profiles'] = $profiles;
|
||||||
|
$data['nextScheduled'] = $nextScheduled;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage mod_mokosuitebackup_cpanel
|
||||||
|
* @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\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
|
/** @var array $displayData */
|
||||||
|
$status = $displayData['status'];
|
||||||
|
$profiles = $displayData['profiles'];
|
||||||
|
$nextScheduled = $displayData['nextScheduled'];
|
||||||
|
$params = $displayData['params'];
|
||||||
|
|
||||||
|
$showButtons = (int) $params->get('show_backup_buttons', 1);
|
||||||
|
$showSchedule = (int) $params->get('show_schedule', 1);
|
||||||
|
|
||||||
|
$latest = $status['latest'] ?? null;
|
||||||
|
$installed = $status['installed'] ?? false;
|
||||||
|
$totals = $status['totals'] ?? [];
|
||||||
|
|
||||||
|
$ajaxToken = Session::getFormToken();
|
||||||
|
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
|
||||||
|
|
||||||
|
$moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if (!$installed) : ?>
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED'); ?>
|
||||||
|
</div>
|
||||||
|
<?php return; endif; ?>
|
||||||
|
|
||||||
|
<div id="<?php echo $moduleId; ?>" class="mod-mokosuitebackup-cpanel">
|
||||||
|
<!-- Last Backup Status -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small mb-2">
|
||||||
|
<span class="icon-database" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP'); ?>
|
||||||
|
</h6>
|
||||||
|
<?php if ($latest) : ?>
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="badge <?php echo $latest['status'] === 'complete' ? 'bg-success' : 'bg-danger'; ?>">
|
||||||
|
<?php echo $latest['status'] === 'complete'
|
||||||
|
? Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK')
|
||||||
|
: Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'); ?>
|
||||||
|
</span>
|
||||||
|
<span class="ms-1 small text-muted">
|
||||||
|
<?php echo htmlspecialchars($latest['profile'] ?? ''); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="small text-muted">
|
||||||
|
<?php echo HTMLHelper::_('date', $latest['backup_start'], Text::_('DATE_FORMAT_LC4')); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
<?php echo HTMLHelper::_('number.bytes', (int) $latest['total_size']); ?>
|
||||||
|
— <?php echo Text::sprintf('MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES', (int) $latest['files_count'], (int) $latest['tables_count']); ?>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<p class="text-muted small mb-0"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Scheduled -->
|
||||||
|
<?php if ($showSchedule && $nextScheduled) : ?>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small mb-1">
|
||||||
|
<span class="icon-calendar" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED'); ?>
|
||||||
|
</h6>
|
||||||
|
<div class="small">
|
||||||
|
<?php echo HTMLHelper::_('date', $nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
|
||||||
|
<span class="text-muted">— <?php echo htmlspecialchars($nextScheduled->title); ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<?php if (!empty($totals)) : ?>
|
||||||
|
<div class="d-flex gap-3 mb-3 small">
|
||||||
|
<div>
|
||||||
|
<span class="fw-bold"><?php echo (int) ($totals['all_time'] ?? 0); ?></span>
|
||||||
|
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_TOTAL'); ?></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="fw-bold text-success"><?php echo (int) ($totals['success_streak'] ?? 0); ?></span>
|
||||||
|
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STREAK'); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if (($totals['recent_failed'] ?? 0) > 0) : ?>
|
||||||
|
<div>
|
||||||
|
<span class="fw-bold text-danger"><?php echo (int) $totals['recent_failed']; ?></span>
|
||||||
|
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D'); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Backup Now Buttons -->
|
||||||
|
<?php if ($showButtons && !empty($profiles)) : ?>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small mb-2">
|
||||||
|
<span class="icon-download" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW'); ?>
|
||||||
|
</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<?php foreach ($profiles as $profile) : ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary msb-cpanel-backup-btn"
|
||||||
|
data-profile-id="<?php echo (int) $profile->id; ?>"
|
||||||
|
data-module-id="<?php echo $moduleId; ?>">
|
||||||
|
#<?php echo (int) $profile->id; ?> <?php echo htmlspecialchars($profile->title); ?>
|
||||||
|
<span class="badge bg-secondary ms-1"><?php echo htmlspecialchars($profile->backup_type); ?></span>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="list-group list-group-flush small">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>"
|
||||||
|
class="list-group-item list-group-item-action px-0 py-1">
|
||||||
|
<span class="icon-database" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS'); ?>
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots&task=snapshot.add'); ?>"
|
||||||
|
class="list-group-item list-group-item-action px-0 py-1">
|
||||||
|
<span class="icon-camera" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT'); ?>
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=profiles'); ?>"
|
||||||
|
class="list-group-item list-group-item-action px-0 py-1">
|
||||||
|
<span class="icon-cog" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepped Backup Modal -->
|
||||||
|
<div id="<?php echo $moduleId; ?>-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||||
|
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||||
|
<h3 id="<?php echo $moduleId; ?>-modal-title" style="margin:0 0 1rem;"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS'); ?></h3>
|
||||||
|
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||||
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
|
<strong><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE'); ?></strong>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||||
|
<div id="<?php echo $moduleId; ?>-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||||
|
</div>
|
||||||
|
<p id="<?php echo $moduleId; ?>-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
||||||
|
<p id="<?php echo $moduleId; ?>-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var MOD_ID = <?php echo json_encode($moduleId); ?>;
|
||||||
|
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
|
var TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||||
|
|
||||||
|
var running = false;
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
if (running) { e.preventDefault(); e.returnValue = ''; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
running = true;
|
||||||
|
el(MOD_ID + '-modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal() {
|
||||||
|
running = false;
|
||||||
|
el(MOD_ID + '-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(pct, msg, phase) {
|
||||||
|
var bar = el(MOD_ID + '-progress-bar');
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
bar.textContent = pct + '%';
|
||||||
|
el(MOD_ID + '-status').textContent = msg;
|
||||||
|
el(MOD_ID + '-phase').textContent = 'Phase: ' + phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAjax(params) {
|
||||||
|
var form = new URLSearchParams();
|
||||||
|
form.append(TOKEN, '1');
|
||||||
|
for (var k in params) { form.append(k, params[k]); }
|
||||||
|
var res = await fetch(AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBackup(profileId) {
|
||||||
|
showModal();
|
||||||
|
updateProgress(0, 'Initializing backup...', 'init');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var initResult = await postAjax({ task: 'ajax.init', profile_id: profileId });
|
||||||
|
if (initResult.error) {
|
||||||
|
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||||
|
setTimeout(hideModal, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = initResult.session_id;
|
||||||
|
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||||
|
|
||||||
|
var done = false;
|
||||||
|
while (!done) {
|
||||||
|
var stepResult = await postAjax({ task: 'ajax.step', session_id: sessionId });
|
||||||
|
if (stepResult.error) {
|
||||||
|
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
|
||||||
|
setTimeout(hideModal, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
|
||||||
|
done = stepResult.done || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
el(MOD_ID + '-modal-title').textContent = <?php echo json_encode(Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE')); ?>;
|
||||||
|
setTimeout(function() { hideModal(); location.reload(); }, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
updateProgress(0, 'ERROR: ' + err.message, 'failed');
|
||||||
|
setTimeout(hideModal, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('#' + MOD_ID + ' .msb-cpanel-backup-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
startBackup(this.getAttribute('data-profile-id'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
|||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||||
|
|||||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
|||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="actionlog" method="upgrade">
|
<extension type="plugin" group="actionlog" method="upgrade">
|
||||||
<name>Action Log - MokoSuiteBackup</name>
|
<name>Action Log - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
+91
@@ -28,6 +28,9 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
|||||||
'onContentAfterSave' => 'onContentAfterSave',
|
'onContentAfterSave' => 'onContentAfterSave',
|
||||||
'onContentAfterDelete' => 'onContentAfterDelete',
|
'onContentAfterDelete' => 'onContentAfterDelete',
|
||||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||||
|
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
|
||||||
|
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
|
||||||
|
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a backup is restored.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterRestore(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$recordId = $args['record_id'] ?? 0;
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $recordId,
|
||||||
|
'title' => 'Backup #' . $recordId,
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.backup',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a content snapshot is created.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||||
|
$contentTypes = $args['content_types'] ?? [];
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $snapshotId,
|
||||||
|
'title' => 'Snapshot #' . $snapshotId,
|
||||||
|
'content_types' => implode(', ', $contentTypes),
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.snapshot',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a snapshot is restored.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||||
|
$mode = $args['mode'] ?? 'replace';
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $snapshotId,
|
||||||
|
'title' => 'Snapshot #' . $snapshotId,
|
||||||
|
'mode' => $mode,
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.snapshot',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write an action log entry.
|
* Write an action log entry.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="console" method="upgrade">
|
<extension type="plugin" group="console" method="upgrade">
|
||||||
<name>Console - MokoSuiteBackup</name>
|
<name>Console - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="content" method="upgrade">
|
<extension type="plugin" group="content" method="upgrade">
|
||||||
<name>Content - MokoSuiteBackup</name>
|
<name>Content - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<extension type="plugin" group="quickicon" method="upgrade">
|
<extension type="plugin" group="quickicon" method="upgrade">
|
||||||
<name>Quick Icon - MokoSuiteBackup</name>
|
<name>Quick Icon - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="system" method="upgrade">
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
<name>System - MokoSuiteBackup</name>
|
<name>System - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="task" method="upgrade">
|
<extension type="plugin" group="task" method="upgrade">
|
||||||
<name>Task - MokoSuiteBackup</name>
|
<name>Task - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="webservices" method="upgrade">
|
<extension type="plugin" group="webservices" method="upgrade">
|
||||||
<name>Web Services - MokoSuiteBackup</name>
|
<name>Web Services - MokoSuiteBackup</name>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteBackup</name>
|
<name>Package - MokoSuiteBackup</name>
|
||||||
<packagename>mokosuitebackup</packagename>
|
<packagename>mokosuitebackup</packagename>
|
||||||
<version>01.35.00</version>
|
<version>01.43.32</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>
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
|
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
|
||||||
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
|
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
|
||||||
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
|
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
|
||||||
|
<file type="module" id="mod_mokosuitebackup_cpanel" client="administrator">mod_mokosuitebackup_cpanel.zip</file>
|
||||||
|
<file type="package" id="pkg_mokosuiteclient">MokoSuiteClient.zip</file>
|
||||||
</files>
|
</files>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|||||||
Reference in New Issue
Block a user