Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11244374b0 | |||
| 0fe14bf19b | |||
| 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 | |||
| 4df70531e2 | |||
| 845b856cda | |||
| 633e9b7f1e | |||
| ec0b7eb8a4 | |||
| 7d119565da | |||
| 9db7331a72 | |||
| 32931c1e37 |
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -27,7 +27,7 @@ name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.00.00
|
||||
# VERSION: 01.43.12
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+534
-534
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+147
-19
@@ -2,29 +2,157 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
### Added
|
||||
- MokoRestore: post-restore reset options — passwords, hits, versions, sessions, cache (#131)
|
||||
- MokoRestore: per-table conflict resolution — replace, skip, merge, data-only per table (#132)
|
||||
- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users"
|
||||
- MokoRestore: auto-detect sanitized passwords and prompt for reset
|
||||
- Data sanitization: passwords, emails, sessions in backup profile settings (#129)
|
||||
- Manual purge: delete all backups older than a selected date with count preview (#119)
|
||||
- CPanel admin dashboard module with backup status, quick actions, and profile buttons (#105)
|
||||
- 7z archive format via system 7za/7z binary with optional password encryption (#122)
|
||||
- SFTP remote file browser: browse remote server directories to select backup path (#98)
|
||||
- Customizable restore script filename per backup profile (reduces discoverability on remote servers)
|
||||
- MokoRestore standalone mode: multi-ZIP selector when multiple backup archives are present
|
||||
- MokoRestore preflight: Joomla installation detection warning before overwriting an existing site
|
||||
- MokoRestore error handling: try/catch on fetch calls, HTTP status checks, JSON parse recovery
|
||||
- Download button on individual backup record detail toolbar
|
||||
- Profile column in backup records list links to the profile edit view
|
||||
|
||||
### Changed
|
||||
- Moved download, browse archive, and view log actions from backup list rows into the individual backup record view
|
||||
- Removed "Run Backup" / "Backup Now" buttons from profiles list, profile edit toolbar, and backup records view (backups are triggered from the dashboard only)
|
||||
- Removed ordering field from profiles; default sort is now by ID ascending
|
||||
- MokoRestore cleanup and security messages now reference the actual script filename instead of hardcoded "restore.php"
|
||||
|
||||
### Fixed
|
||||
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
|
||||
- MokoRestore: temporary password is now randomly generated (not hardcoded "changeme")
|
||||
- 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.38.05] --- 2026-06-23
|
||||
## [01.43.00] --- 2026-06-24
|
||||
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
## [01.43.00] --- 2026-06-24
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
## [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
|
||||
- Initial release: full-site backup and restore for Joomla 6
|
||||
- Database, files, and configuration backup
|
||||
- ZIP and tar.gz archive formats with AES-256 encryption
|
||||
- Differential backups based on file manifests
|
||||
- FTP/FTPS, S3, Google Drive remote storage
|
||||
- MokoRestore standalone restore wizard
|
||||
- CLI backup and restore commands
|
||||
- REST API for remote management
|
||||
- Scheduled tasks via com_scheduler
|
||||
- Email and ntfy push notifications
|
||||
- Per-profile retention, exclusions, and notifications
|
||||
- Akeeba Backup migration tool
|
||||
- Admin dashboard with system health checks
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||
<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>
|
||||
</access>
|
||||
|
||||
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
|
||||
*/
|
||||
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);
|
||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||
$this->app->close();
|
||||
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
|
||||
*/
|
||||
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);
|
||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||
$this->app->close();
|
||||
|
||||
@@ -39,6 +39,73 @@
|
||||
</field>
|
||||
</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">
|
||||
<field
|
||||
name="webcron_secret"
|
||||
@@ -172,6 +239,32 @@
|
||||
</field>
|
||||
</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"
|
||||
description="JCONFIG_PERMISSIONS_DESC">
|
||||
<field
|
||||
|
||||
@@ -24,10 +24,9 @@
|
||||
name="fullordering"
|
||||
type="list"
|
||||
label="JGLOBAL_SORT_BY"
|
||||
default="a.ordering ASC"
|
||||
default="a.id ASC"
|
||||
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 DESC">COM_MOKOJOOMBACKUP_HEADING_TITLE_DESC</option>
|
||||
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</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
|
||||
name="encryption_password"
|
||||
type="password"
|
||||
@@ -164,12 +174,6 @@
|
||||
<option value="1">JPUBLISHED</option>
|
||||
<option value="0">JUNPUBLISHED</option>
|
||||
</field>
|
||||
<field
|
||||
name="ordering"
|
||||
type="number"
|
||||
label="JFIELD_ORDERING_LABEL"
|
||||
default="0"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="filters" label="COM_MOKOJOOMBACKUP_FIELDSET_FILTERS">
|
||||
@@ -202,6 +206,13 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||
<field
|
||||
name="remote_legacy_note"
|
||||
type="note"
|
||||
label=""
|
||||
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
|
||||
class="alert alert-info small"
|
||||
/>
|
||||
<field
|
||||
name="remote_storage"
|
||||
type="list"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
|
||||
COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
|
||||
|
||||
; Submenu
|
||||
@@ -127,29 +128,31 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)"
|
||||
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
||||
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
||||
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_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_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_DESC="Filename template for backup archives (without extension). Placeholders: [HOST] hostname, [DATE] Ymd, [TIME] His, [DATETIME] Ymd_His, [YEAR] [MONTH] [DAY] [HOUR] [MINUTE] [SECOND], [PROFILE_ID], [PROFILE_NAME], [SITE_NAME], [TYPE], [RANDOM]."
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template (without extension). Click the placeholder buttons below to insert tokens."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="None: no restore script. Wrapped: bundled inside the ZIP. Standalone: separate restore.php file (ideal for remote servers)."
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_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 all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace password hashes with invalid values. Users must reset passwords after restore. For demos, staging, or GDPR."
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_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 all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace emails with dummy values. Prevents accidental emails from cloned sites. Super admin preserved if enabled above."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude session data. Logs out all users on restore, prevents session hijacking. Enabled by default."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
@@ -275,9 +278,9 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
||||
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_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||
@@ -414,6 +417,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||
|
||||
; 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
|
||||
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."
|
||||
@@ -463,6 +498,15 @@ 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
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -107,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
KEY `idx_created` (`created`)
|
||||
) 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,
|
||||
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_enabled` (`enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- MokoSuiteBackup 01.41.00 — Multi-remote storage destinations (#97)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Migrate existing SFTP remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - SFTP'),
|
||||
'sftp',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'host', `sftp_host`,
|
||||
'port', `sftp_port`,
|
||||
'username', `sftp_username`,
|
||||
'auth_type', `sftp_auth_type`,
|
||||
'password', `sftp_password`,
|
||||
'key_data', COALESCE(`sftp_key_data`, ''),
|
||||
'passphrase', `sftp_passphrase`,
|
||||
'path', `sftp_path`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'sftp' AND `sftp_host` != '';
|
||||
|
||||
-- Migrate existing S3 remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - S3'),
|
||||
's3',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'endpoint', `s3_endpoint`,
|
||||
'region', `s3_region`,
|
||||
'access_key', `s3_access_key`,
|
||||
'secret_key', `s3_secret_key`,
|
||||
'bucket', `s3_bucket`,
|
||||
'path', `s3_path`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 's3' AND `s3_bucket` != '';
|
||||
|
||||
-- Migrate existing Google Drive remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - Google Drive'),
|
||||
'google_drive',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'client_id', `gdrive_client_id`,
|
||||
'client_secret', `gdrive_client_secret`,
|
||||
'refresh_token', `gdrive_refresh_token`,
|
||||
'folder_id', `gdrive_folder_id`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'google_drive' AND `gdrive_client_id` != '';
|
||||
|
||||
-- Migrate existing FTP remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - FTP'),
|
||||
'ftp',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'host', `ftp_host`,
|
||||
'port', `ftp_port`,
|
||||
'username', `ftp_username`,
|
||||
'password', `ftp_password`,
|
||||
'path', `ftp_path`,
|
||||
'passive', `ftp_passive`,
|
||||
'ssl', `ftp_ssl`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'ftp' AND `ftp_host` != '';
|
||||
@@ -348,7 +348,7 @@ class AjaxController extends BaseController
|
||||
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);
|
||||
|
||||
return;
|
||||
@@ -384,7 +384,7 @@ class AjaxController extends BaseController
|
||||
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);
|
||||
|
||||
return;
|
||||
@@ -416,7 +416,7 @@ class AjaxController extends BaseController
|
||||
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);
|
||||
|
||||
return;
|
||||
@@ -725,7 +725,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -776,7 +776,7 @@ class AjaxController extends BaseController
|
||||
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);
|
||||
|
||||
return;
|
||||
@@ -879,6 +879,335 @@ 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 config and mask secrets
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$config = json_decode($row->config, 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,
|
||||
'keep_local' => (int) $row->keep_local,
|
||||
'config' => $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);
|
||||
$keepLocal = $this->input->getInt('remote_keep_local', 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->keep_local = $keepLocal ? 1 : 0;
|
||||
$table->config = 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('config'))
|
||||
->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
|
||||
|
||||
@@ -176,7 +176,7 @@ class BackupsController extends AdminController
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ class SnapshotsController extends AdminController
|
||||
{
|
||||
$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->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
|
||||
@@ -259,14 +259,14 @@ class BackupEngine
|
||||
|
||||
// Step 2.5: MokoRestore script (if enabled)
|
||||
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||
$restoreScriptName = $profile->restore_script_name ?? 'restore.php';
|
||||
$restoreScriptPath = '';
|
||||
|
||||
if ($mokoRestoreMode === '1') {
|
||||
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
||||
$this->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath, $restoreScriptName);
|
||||
|
||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||
@@ -278,57 +278,90 @@ class BackupEngine
|
||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||
} elseif ($mokoRestoreMode === 'standalone') {
|
||||
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
||||
$this->log('Generating standalone restore.php...');
|
||||
$restoreScriptPath = $this->backupDir . '/restore.php';
|
||||
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||
$this->log('Generating standalone ' . $restoreScriptName . '...');
|
||||
$restoreScriptPath = $this->backupDir . '/' . $restoreScriptName;
|
||||
MokoRestore::generateStandalone($restoreScriptPath);
|
||||
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||
$this->log('Standalone ' . $restoreScriptName . ' generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||
}
|
||||
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
// Step 3: Remote upload (if configured)
|
||||
// Wrapped in its own try-catch so a remote failure does not mark
|
||||
// the entire backup as failed — the local archive is preserved.
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
/* Step 3: Remote upload — iterate all enabled destinations */
|
||||
$remotes = $this->loadRemoteDestinations($db, $profileId);
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
try {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
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 ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||
$this->log(' Upload complete: ' . $result['message']);
|
||||
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$uploader->upload($restoreScriptPath, basename($restoreScriptPath));
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$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';
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
try {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
|
||||
if ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$restoreBasename = basename($restoreScriptPath);
|
||||
$this->log('Uploading standalone ' . $restoreBasename . '...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename);
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone ' . $restoreBasename . ' uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +520,8 @@ class BackupEngine
|
||||
|
||||
/**
|
||||
* Create the appropriate remote uploader based on the storage type.
|
||||
* Legacy method — used by backward-compat fallback when remotes table
|
||||
* does not exist.
|
||||
*/
|
||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||
{
|
||||
@@ -499,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.
|
||||
* Used by differential backups to determine which files changed.
|
||||
|
||||
@@ -35,25 +35,36 @@ class MokoRestore
|
||||
*
|
||||
* @return string Path to the wrapped archive
|
||||
*/
|
||||
public static function wrap(string $backupArchive, string $outputPath): string
|
||||
public static function wrap(string $backupArchive, string $outputPath, string $scriptName = 'restore.php'): string
|
||||
{
|
||||
$scriptName = self::sanitizeScriptName($scriptName);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
|
||||
}
|
||||
|
||||
// Add the standalone restore script
|
||||
$zip->addFromString('restore.php', self::generateRestoreScript());
|
||||
|
||||
// Add the original backup as a nested ZIP
|
||||
$zip->addFromString($scriptName, self::generateRestoreScript());
|
||||
$zip->addFile($backupArchive, 'site-backup.zip');
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
public static function sanitizeScriptName(string $name): string
|
||||
{
|
||||
$name = basename(trim($name));
|
||||
|
||||
if ($name === '' || !str_ends_with(strtolower($name), '.php')) {
|
||||
$name = 'restore.php';
|
||||
}
|
||||
|
||||
$name = preg_replace('/[^a-zA-Z0-9._-]/', '', $name);
|
||||
|
||||
return $name ?: 'restore.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script as a separate file.
|
||||
*
|
||||
@@ -165,7 +176,38 @@ SCANNER;
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify the pre-checks to use getSelectedBackupFile() */
|
||||
/* Replace the backup archive check with one that scans for ZIPs
|
||||
(must run BEFORE the blanket file_exists replacement below) */
|
||||
$php = str_replace(
|
||||
<<<'ORIG'
|
||||
$checks[] = [
|
||||
'label' => 'Backup Archive',
|
||||
'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found',
|
||||
'ok' => file_exists(BACKUP_FILE),
|
||||
'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||
];
|
||||
ORIG,
|
||||
<<<'REPL'
|
||||
$availableBackups = scanForBackups();
|
||||
$backupCount = count($availableBackups);
|
||||
$selectedFile = getSelectedBackupFile();
|
||||
if ($selectedFile && file_exists($selectedFile)) {
|
||||
$archiveValue = basename($selectedFile) . ' (' . number_format(filesize($selectedFile) / 1048576, 2) . ' MB)';
|
||||
} elseif ($backupCount > 0) {
|
||||
$archiveValue = $backupCount . ' ZIP file(s) found';
|
||||
} else {
|
||||
$archiveValue = 'No ZIP files found';
|
||||
}
|
||||
$checks[] = [
|
||||
'label' => 'Backup Archive',
|
||||
'value' => $archiveValue,
|
||||
'ok' => $backupCount > 0,
|
||||
'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||
];
|
||||
REPL
|
||||
);
|
||||
|
||||
/* Modify remaining pre-checks to use getSelectedBackupFile() */
|
||||
$php = str_replace(
|
||||
"file_exists(BACKUP_FILE)",
|
||||
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||
@@ -174,65 +216,83 @@ SCANNER;
|
||||
|
||||
$html = self::generateFrontend();
|
||||
|
||||
/* Add backup file selector to the frontend before the extract step */
|
||||
/* Inject backup file selector into the extract step (panel2) */
|
||||
$selectorHtml = <<<'SELECTOR'
|
||||
<!-- Backup File Selector (standalone mode) -->
|
||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||
<h2 class="mr-step-title">Select Backup File</h2>
|
||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
<div id="mr-backup-selector" class="mb-3">
|
||||
<label class="mr-field-label" style="font-weight:600;margin-bottom:8px;display:block;">Backup Archive</label>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.className = 'mr-alert mr-alert-danger';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.className = 'mr-alert mr-alert-success';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'mr-field-group';
|
||||
backups.forEach(function(b) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '8px';
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
label.appendChild(nameStrong);
|
||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||
group.appendChild(label);
|
||||
});
|
||||
list.appendChild(group);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.style.cssText = 'padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626;';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.style.cssText = 'padding:12px;background:#dcfce7;border:1px solid #bbf7d0;border-radius:6px;color:#16a34a;';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1d4ed8;margin-bottom:8px;font-size:0.9em;';
|
||||
hint.textContent = 'Multiple backup archives found \u2014 select which one to restore:';
|
||||
list.appendChild(hint);
|
||||
backups.forEach(function(b, i) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:background 0.15s;';
|
||||
label.onmouseover = function() { this.style.background = '#f8fafc'; };
|
||||
label.onmouseout = function() { this.style.background = ''; };
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '10px';
|
||||
if (i === 0) { radio.checked = true; hiddenInput.value = b.name; }
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var info = document.createElement('div');
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
info.appendChild(nameStrong);
|
||||
var meta = document.createElement('div');
|
||||
meta.style.cssText = 'font-size:0.85em;color:#64748b;margin-top:2px;';
|
||||
meta.textContent = (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date;
|
||||
info.appendChild(meta);
|
||||
label.appendChild(info);
|
||||
list.appendChild(label);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
SELECTOR;
|
||||
|
||||
/* Insert the selector before the extract step in the HTML */
|
||||
/* Insert the selector into the extract panel */
|
||||
$html = str_replace(
|
||||
'<!-- Step: Extract -->',
|
||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||
'<p class="mr-desc">Extract site-backup.zip into the current directory.</p>',
|
||||
'<p class="mr-desc">Select a backup archive and extract it into the current directory.</p>' . "\n" . $selectorHtml,
|
||||
$html
|
||||
);
|
||||
|
||||
/* Pass selected backup file to the extract action */
|
||||
$html = str_replace(
|
||||
"const r = await post('extract', pw ? { archive_password: pw } : {});",
|
||||
"var extraParams = {};\n" .
|
||||
" if (pw) extraParams.archive_password = pw;\n" .
|
||||
" var sel = document.getElementById('mr-backup-file');\n" .
|
||||
" if (sel && sel.value) extraParams.backup_file = sel.value;\n" .
|
||||
" const r = await post('extract', extraParams);",
|
||||
$html
|
||||
);
|
||||
|
||||
@@ -435,7 +495,7 @@ function actionPreflight(): array
|
||||
'label' => 'Backup Archive',
|
||||
'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found',
|
||||
'ok' => file_exists(BACKUP_FILE),
|
||||
'hint' => 'site-backup.zip must be in the same directory as restore.php',
|
||||
'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
|
||||
];
|
||||
|
||||
$checks[] = [
|
||||
@@ -462,15 +522,31 @@ function actionPreflight(): array
|
||||
'hint' => 'Informational',
|
||||
];
|
||||
|
||||
$joomlaExists = file_exists(RESTORE_DIR . '/configuration.php')
|
||||
|| file_exists(RESTORE_DIR . '/libraries/src/Version.php');
|
||||
$checks[] = [
|
||||
'label' => 'Existing Installation',
|
||||
'value' => $joomlaExists ? 'Joomla detected' : 'Clean directory',
|
||||
'ok' => true,
|
||||
'warn' => $joomlaExists,
|
||||
'hint' => $joomlaExists
|
||||
? 'WARNING: A Joomla installation already exists in this directory. Restoring will overwrite it.'
|
||||
: 'No existing installation found — safe to proceed',
|
||||
];
|
||||
|
||||
$allOk = true;
|
||||
$warnings = [];
|
||||
|
||||
foreach ($checks as $c) {
|
||||
if (!$c['ok']) {
|
||||
$allOk = false;
|
||||
}
|
||||
if (!empty($c['warn'])) {
|
||||
$warnings[] = $c['hint'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => $allOk, 'checks' => $checks];
|
||||
return ['success' => $allOk, 'checks' => $checks, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
function actionExtract(array $data): array
|
||||
@@ -1425,6 +1501,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
.mr-checks li:last-child{border-bottom:none}
|
||||
.mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0}
|
||||
.mr-check-ok{background:#dcfce7;color:#16a34a}
|
||||
.mr-check-warn{background:#fef9c3;color:#a16207}
|
||||
.mr-check-fail{background:#fef2f2;color:#dc2626}
|
||||
.mr-check-info{background:#e0f2fe;color:#0284c7}
|
||||
.mr-check-label{flex:1;font-weight:500}
|
||||
@@ -1474,7 +1551,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
|
||||
<div class="mr-container">
|
||||
<div class="mr-alert mr-alert-danger">
|
||||
<strong>Security:</strong> Delete restore.php immediately after installation is complete.
|
||||
<strong>Security:</strong> Delete <code><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></code> immediately after installation is complete.
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
@@ -1722,7 +1799,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
<strong>Success!</strong> The site restoration is complete.
|
||||
</div>
|
||||
<div class="mr-alert mr-alert-danger">
|
||||
<strong>Important:</strong> Delete <code>restore.php</code> and <code>site-backup.zip</code> from your server immediately for security.
|
||||
<strong>Important:</strong> Delete <code><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></code> and <code>site-backup.zip</code> from your server immediately for security.
|
||||
</div>
|
||||
<div style="margin-top:1rem">
|
||||
<button class="mr-btn mr-btn-danger" onclick="runCleanup()">Remove Restore Files</button>
|
||||
@@ -1746,6 +1823,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
|
||||
<script>
|
||||
const TOKEN = <?php echo json_encode($token); ?>;
|
||||
const SCRIPT_URL = <?php echo json_encode(basename($_SERVER['SCRIPT_NAME'])); ?>;
|
||||
let currentStep = 1;
|
||||
let dbConfig = {};
|
||||
|
||||
@@ -1769,8 +1847,23 @@ async function post(action, extra) {
|
||||
form.append(k, v);
|
||||
}
|
||||
}
|
||||
const res = await fetch('restore.php', { method: 'POST', body: form });
|
||||
return res.json();
|
||||
var res;
|
||||
try {
|
||||
res = await fetch(SCRIPT_URL, { method: 'POST', body: form });
|
||||
} catch (e) {
|
||||
log('Network error: ' + e.message);
|
||||
return { success: false, message: 'Network error: ' + e.message, checks: [] };
|
||||
}
|
||||
if (!res.ok) {
|
||||
log('Server error: HTTP ' + res.status);
|
||||
return { success: false, message: 'Server error (HTTP ' + res.status + ')', checks: [] };
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
log('Invalid response from server (not JSON)');
|
||||
return { success: false, message: 'Invalid server response — check PHP error log', checks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function goStep(n) {
|
||||
@@ -1845,42 +1938,66 @@ async function runPreflight() {
|
||||
setBtnLoading(btn, true);
|
||||
log('Running pre-flight checks...');
|
||||
|
||||
const r = await post('preflight');
|
||||
const list = document.getElementById('checkList');
|
||||
while (list.firstChild) list.removeChild(list.firstChild);
|
||||
try {
|
||||
const r = await post('preflight');
|
||||
|
||||
r.checks.forEach(function(c) {
|
||||
const li = document.createElement('li');
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'mr-check-icon ' + (c.ok ? 'mr-check-ok' : 'mr-check-fail');
|
||||
icon.textContent = c.ok ? '\u2713' : '\u2717';
|
||||
if (!r.success && !r.checks.length) {
|
||||
log('Pre-flight error: ' + (r.message || 'Unknown error'));
|
||||
setBtnLoading(btn, false);
|
||||
btn.textContent = 'Re-check';
|
||||
setStatus('checkList', r.message || 'Pre-flight check failed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'mr-check-label';
|
||||
label.textContent = c.label;
|
||||
const list = document.getElementById('checkList');
|
||||
while (list.firstChild) list.removeChild(list.firstChild);
|
||||
|
||||
const val = document.createElement('span');
|
||||
val.className = 'mr-check-value';
|
||||
val.textContent = c.value;
|
||||
r.checks.forEach(function(c) {
|
||||
const li = document.createElement('li');
|
||||
const icon = document.createElement('span');
|
||||
var iconClass = c.ok ? 'mr-check-ok' : 'mr-check-fail';
|
||||
if (c.warn) iconClass = 'mr-check-warn';
|
||||
icon.className = 'mr-check-icon ' + iconClass;
|
||||
icon.textContent = c.warn ? '\u26a0' : (c.ok ? '\u2713' : '\u2717');
|
||||
|
||||
li.appendChild(icon);
|
||||
li.appendChild(label);
|
||||
li.appendChild(val);
|
||||
list.appendChild(li);
|
||||
const label = document.createElement('span');
|
||||
label.className = 'mr-check-label';
|
||||
label.textContent = c.label;
|
||||
|
||||
log(' ' + (c.ok ? 'OK' : 'FAIL') + ': ' + c.label + ' = ' + c.value);
|
||||
});
|
||||
const val = document.createElement('span');
|
||||
val.className = 'mr-check-value';
|
||||
val.textContent = c.value;
|
||||
|
||||
setBtnLoading(btn, false);
|
||||
li.appendChild(icon);
|
||||
li.appendChild(label);
|
||||
li.appendChild(val);
|
||||
if (c.warn && c.hint) {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'font-size:0.85em;color:#a16207;margin-top:4px;padding:4px 8px;background:#fef9c3;border-radius:4px;';
|
||||
hint.textContent = c.hint;
|
||||
li.appendChild(hint);
|
||||
}
|
||||
list.appendChild(li);
|
||||
|
||||
if (r.success) {
|
||||
btn.textContent = 'Next \u2192';
|
||||
btn.onclick = function() { goStep(2); };
|
||||
btn.className = 'mr-btn mr-btn-success';
|
||||
log('All checks passed');
|
||||
} else {
|
||||
var logPrefix = c.warn ? 'WARN' : (c.ok ? 'OK' : 'FAIL');
|
||||
log(' ' + logPrefix + ': ' + c.label + ' = ' + c.value);
|
||||
});
|
||||
|
||||
setBtnLoading(btn, false);
|
||||
|
||||
if (r.success) {
|
||||
btn.textContent = 'Next \u2192';
|
||||
btn.onclick = function() { goStep(2); };
|
||||
btn.className = 'mr-btn mr-btn-success';
|
||||
log('All checks passed');
|
||||
} else {
|
||||
btn.textContent = 'Re-check';
|
||||
log('Some checks failed');
|
||||
}
|
||||
} catch (e) {
|
||||
log('Pre-flight error: ' + e.message);
|
||||
setBtnLoading(btn, false);
|
||||
btn.textContent = 'Re-check';
|
||||
log('Some checks failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,32 @@ class PlaceholderResolver
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$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 = '';
|
||||
|
||||
|
||||
@@ -70,9 +70,14 @@ class SteppedBackupEngine
|
||||
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$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);
|
||||
|
||||
// Load multi-remote destinations from the remotes table
|
||||
$session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId);
|
||||
$session->remoteIndex = 0;
|
||||
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
||||
@@ -147,13 +152,22 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
$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->currentStep = 1;
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')');
|
||||
// Log any preflight warnings into the session
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||
@@ -364,15 +378,24 @@ class SteppedBackupEngine
|
||||
$this->verifyArchive($session->archivePath, $session->backupType);
|
||||
$session->log('Archive integrity verified');
|
||||
|
||||
// MokoRestore wrapper
|
||||
if ($session->includeMokoRestore) {
|
||||
// MokoRestore
|
||||
$mokoRestoreMode = $session->includeMokoRestore ?? '0';
|
||||
$restoreScriptName = $session->restoreScriptName ?? 'restore.php';
|
||||
|
||||
if ($mokoRestoreMode === '1') {
|
||||
$session->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestorePath = $session->archivePath . '.mokorestore.zip';
|
||||
MokoRestore::wrap($session->archivePath, $mokoRestorePath);
|
||||
MokoRestore::wrap($session->archivePath, $mokoRestorePath, $restoreScriptName);
|
||||
@unlink($session->archivePath);
|
||||
rename($mokoRestorePath, $session->archivePath);
|
||||
$totalSize = filesize($session->archivePath);
|
||||
$session->log('MokoRestore archive created');
|
||||
} elseif ($mokoRestoreMode === 'standalone') {
|
||||
$restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName);
|
||||
$restoreDir = dirname($session->archivePath);
|
||||
$session->restoreScriptPath = $restoreDir . '/' . $restoreScriptName;
|
||||
MokoRestore::generateStandalone($session->restoreScriptPath);
|
||||
$session->log('Standalone ' . $restoreScriptName . ' generated');
|
||||
}
|
||||
|
||||
// Update record
|
||||
@@ -391,7 +414,17 @@ class SteppedBackupEngine
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$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->log('Archive finalized: ' . $sizeHuman);
|
||||
|
||||
@@ -402,6 +435,10 @@ class SteppedBackupEngine
|
||||
|
||||
/**
|
||||
* Upload phase: send archive to remote storage.
|
||||
*
|
||||
* When multi-remote destinations are configured, each call uploads to
|
||||
* one destination (one step per remote). When only the legacy
|
||||
* single-remote column is set, uploads in a single step.
|
||||
*/
|
||||
private function stepUpload(SteppedSession $session): void
|
||||
{
|
||||
@@ -409,62 +446,136 @@ class SteppedBackupEngine
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
// Wrapped in its own try-catch so a remote failure does not mark
|
||||
// the entire backup as failed — the local archive is preserved.
|
||||
try {
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
if (!empty($session->remoteDestinations)) {
|
||||
// ── Multi-remote path ──────────────────────────────────
|
||||
$index = $session->remoteIndex;
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
if ($index >= count($session->remoteDestinations)) {
|
||||
// All remotes processed — move to complete
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'All remote uploads finished';
|
||||
$this->completeRecord($session);
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
$remote = (object) $session->remoteDestinations[$index];
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
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']);
|
||||
}
|
||||
} else {
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
$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 {
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
|
||||
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
|
||||
$restoreBasename = basename($session->restoreScriptPath);
|
||||
$session->log('Uploading standalone ' . $restoreBasename . '...');
|
||||
$uploader->upload($session->restoreScriptPath, $restoreBasename);
|
||||
}
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -729,4 +840,58 @@ class SteppedBackupEngine
|
||||
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 $excludeTables = [];
|
||||
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 string $encryptionPassword = '';
|
||||
|
||||
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
|
||||
public array $remoteDestinations = [];
|
||||
public int $remoteIndex = 0;
|
||||
|
||||
// Progress
|
||||
public int $totalSteps = 0;
|
||||
public int $currentStep = 0;
|
||||
|
||||
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
|
||||
}
|
||||
|
||||
// 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 = '';
|
||||
|
||||
try {
|
||||
|
||||
@@ -60,14 +60,14 @@ class ProfilesModel extends ListModel
|
||||
$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');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
||||
protected function populateState($ordering = 'a.id', $direction = 'ASC'): void
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,12 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\View\Backup;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
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;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
@@ -34,6 +38,24 @@ class HtmlView extends BaseHtmlView
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ class HtmlView extends BaseHtmlView
|
||||
protected $state;
|
||||
public $filterForm;
|
||||
public $activeFilters = [];
|
||||
public $profiles = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
@@ -35,16 +34,6 @@ class HtmlView extends BaseHtmlView
|
||||
$this->filterForm = $this->get('FilterForm');
|
||||
$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->addToolbar();
|
||||
|
||||
@@ -112,22 +101,23 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
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')) {
|
||||
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')) {
|
||||
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);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,16 +55,6 @@ class HtmlView extends BaseHtmlView
|
||||
$toolbar = Toolbar::getInstance();
|
||||
$profileId = (int) $this->item->id;
|
||||
|
||||
// "Run Backup Now" button — links to backup start with CSRF token
|
||||
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
|
||||
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
|
||||
->url($runUrl)
|
||||
->icon('icon-play')
|
||||
->buttonClass('btn btn-success');
|
||||
}
|
||||
|
||||
// "View Backups" link button
|
||||
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||
->url($backupsUrl)
|
||||
|
||||
@@ -31,30 +31,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<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 if (empty($this->items)) : ?>
|
||||
@@ -88,9 +64,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
@@ -111,7 +84,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
||||
<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); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
@@ -139,35 +114,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</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>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
@@ -188,18 +134,24 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</form>
|
||||
|
||||
<!-- 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 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>
|
||||
<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 class="modal fade" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
@@ -208,19 +160,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||
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;
|
||||
|
||||
@@ -235,12 +174,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
|
||||
function showModal() {
|
||||
backupRunning = true;
|
||||
document.getElementById('mokosuitebackup-modal').style.display = 'block';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('mokosuitebackup-modal')).show();
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
backupRunning = false;
|
||||
document.getElementById('mokosuitebackup-modal').style.display = 'none';
|
||||
bootstrap.Modal.getInstance(document.getElementById('mokosuitebackup-modal'))?.hide();
|
||||
}
|
||||
|
||||
function updateProgress(progress, message, phase) {
|
||||
@@ -344,31 +283,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Close restore modal
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') {
|
||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Close restore modal handled by Bootstrap data-bs-dismiss
|
||||
|
||||
// AJAX stepped restore
|
||||
var restoreRunning = false;
|
||||
|
||||
function showRestoreProgress() {
|
||||
restoreRunning = true;
|
||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
||||
document.getElementById('mb-restore-progress-modal').style.display = 'block';
|
||||
bootstrap.Modal.getInstance(document.getElementById('mb-restore-modal'))?.hide();
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-progress-modal')).show();
|
||||
}
|
||||
|
||||
function hideRestoreProgress() {
|
||||
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) {
|
||||
@@ -457,310 +391,154 @@ $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>
|
||||
|
||||
<!-- Restore Confirmation Modal -->
|
||||
<div id="mb-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-restore-close" aria-label="Close"></button>
|
||||
<div class="modal fade" id="mb-restore-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
|
||||
<label class="form-check-label" for="mb-restore-files">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
|
||||
<label class="form-check-label" for="mb-restore-db">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
|
||||
<label class="form-check-label" for="mb-restore-config">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
|
||||
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
|
||||
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
|
||||
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div 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-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
|
||||
<label class="form-check-label" for="mb-restore-files">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
|
||||
<label class="form-check-label" for="mb-restore-db">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
|
||||
<label class="form-check-label" for="mb-restore-config">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
|
||||
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
|
||||
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
|
||||
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-restore-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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-restore-title" style="margin:0 0 1rem;">Restore in Progress</h3>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<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>
|
||||
</div>
|
||||
<p id="mb-restore-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
||||
<p id="mb-restore-phase" style="color:#999; font-size:0.8rem; margin:0;">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 class="modal fade" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="mb-restore-title">Restore in Progress</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<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>
|
||||
<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>
|
||||
</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;">
|
||||
<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;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
|
||||
</h4>
|
||||
<button type="button" class="btn-close mb-browse-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1.5rem; border-bottom:1px solid #dee2e6; background:#f8f9fa;">
|
||||
<small id="mb-browse-summary" class="text-muted"></small>
|
||||
</div>
|
||||
<div style="padding:0; overflow-y:auto; flex:1;">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
|
||||
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
|
||||
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge Backups Modal -->
|
||||
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
|
||||
<?php if ($canDelete) : ?>
|
||||
<div id="mb-purge-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
|
||||
</h4>
|
||||
<button type="button" class="btn-close mb-purge-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
|
||||
<div style="padding:1.5rem;">
|
||||
<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 id="mb-purge-count-wrapper" style="display:none;">
|
||||
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
|
||||
</div>
|
||||
<div id="mb-purge-none-wrapper" style="display:none;">
|
||||
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-purge-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
|
||||
<div class="modal fade" id="mb-purge-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
|
||||
</button>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
|
||||
<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 id="mb-purge-count-wrapper" style="display:none;">
|
||||
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
|
||||
</div>
|
||||
<div id="mb-purge-none-wrapper" style="display:none;">
|
||||
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></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-danger" id="mb-purge-submit" disabled>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 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 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 style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;">
|
||||
<span class="icon-copy" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
||||
</h4>
|
||||
<button type="button" class="btn-close mb-compare-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||
<div id="mb-compare-loading" style="text-align:center; padding:2rem;">
|
||||
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
||||
<div class="modal fade" id="mb-compare-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<span class="icon-copy" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
|
||||
<div id="mb-compare-loading" class="text-center py-4">
|
||||
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
||||
</div>
|
||||
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
|
||||
<table id="mb-compare-table" class="table table-striped" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-compare-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
|
||||
<table id="mb-compare-table" class="table table-striped" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-compare-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -807,7 +585,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
var table = document.getElementById('mb-compare-table');
|
||||
var body = document.getElementById('mb-compare-body');
|
||||
|
||||
modal.style.display = 'block';
|
||||
bootstrap.Modal.getOrCreateInstance(modal).show();
|
||||
loading.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
table.style.display = 'none';
|
||||
@@ -874,12 +652,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
});
|
||||
}
|
||||
|
||||
// Close compare modal
|
||||
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';
|
||||
}
|
||||
});
|
||||
// Compare modal close handled by Bootstrap data-bs-dismiss
|
||||
|
||||
// Intercept Compare toolbar button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -922,7 +695,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
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;
|
||||
document.getElementById('mb-purge-modal').style.display = 'block';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
@@ -936,12 +709,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'mb-purge-modal' || e.target.classList.contains('mb-purge-close')) {
|
||||
document.getElementById('mb-purge-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Purge modal close handled by Bootstrap data-bs-dismiss
|
||||
|
||||
// Confirm on submit
|
||||
var purgeForm = document.getElementById('mb-purge-form');
|
||||
|
||||
@@ -238,6 +238,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
<select id="mb-profile-select" class="form-select mb-2">
|
||||
<?php foreach ($this->profiles as $profile) : ?>
|
||||
<option value="<?php echo (int) $profile->id; ?>">
|
||||
#<?php echo (int) $profile->id; ?> —
|
||||
<?php echo $this->escape($profile->title); ?>
|
||||
(<?php echo $this->escape($profile->backup_type); ?>)
|
||||
</option>
|
||||
|
||||
@@ -13,11 +13,15 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.formvalidator');
|
||||
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">
|
||||
|
||||
<div class="main-card">
|
||||
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<?php echo $this->form->renderFieldset('remote'); ?>
|
||||
<?php echo $this->form->renderFieldset('ftp'); ?>
|
||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||
<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('ftp'); ?>
|
||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
|
||||
<input type="hidden" name="task" value="">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</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; ?>
|
||||
|
||||
@@ -52,9 +52,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
@@ -87,16 +84,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ($item->published == 1) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
|
||||
<span class="icon-play" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
|
||||
@@ -132,117 +132,121 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</form>
|
||||
|
||||
<!-- Create Snapshot Modal -->
|
||||
<div id="mb-snapshot-create-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
<div class="modal fade" id="mb-snapshot-create-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
||||
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="articles" id="mb-snap-articles" checked>
|
||||
<label class="form-check-label" for="mb-snap-articles">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="categories" id="mb-snap-categories" checked>
|
||||
<label class="form-check-label" for="mb-snap-categories">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="modules" id="mb-snap-modules" checked>
|
||||
<label class="form-check-label" for="mb-snap-modules">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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-primary">
|
||||
<span class="icon-camera" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="mb-3">
|
||||
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
||||
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="articles" id="mb-snap-articles" checked>
|
||||
<label class="form-check-label" for="mb-snap-articles">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="categories" id="mb-snap-categories" checked>
|
||||
<label class="form-check-label" for="mb-snap-categories">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="modules" id="mb-snap-modules" checked>
|
||||
<label class="form-check-label" for="mb-snap-modules">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-camera" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Snapshot Modal -->
|
||||
<div id="mb-snapshot-restore-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
<div class="modal fade" id="mb-snapshot-restore-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-id" value="">
|
||||
<div class="modal-body">
|
||||
<p id="mb-restore-desc" class="fw-bold"></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="replace" id="mb-mode-replace" checked>
|
||||
<label class="form-check-label" for="mb-mode-replace">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="merge" id="mb-mode-merge">
|
||||
<label class="form-check-label" for="mb-mode-merge">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="mb-restore-types-container">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div 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-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<p id="mb-restore-desc" class="fw-bold"></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="replace" id="mb-mode-replace" checked>
|
||||
<label class="form-check-label" for="mb-mode-replace">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="merge" id="mb-mode-merge">
|
||||
<label class="form-check-label" for="mb-mode-merge">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="mb-restore-types-container">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
||||
<!-- Populated by JS from data-types -->
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 style="max-width:800px; 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 style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
|
||||
<input type="hidden" name="id" id="mb-browse-id" value="">
|
||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||
<div class="modal fade" id="mb-snapshot-browse-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<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="">
|
||||
<div class="modal-body" style="max-height:60vh; overflow-y:auto;">
|
||||
<div id="mb-browse-loading" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||
@@ -331,8 +335,8 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
|
||||
@@ -340,6 +344,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,7 +357,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
createBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'block';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-snapshot-create-modal')).show();
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
@@ -413,7 +418,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
// Show/hide replace warning based on mode
|
||||
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
|
||||
@@ -454,7 +459,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
tab.show();
|
||||
}
|
||||
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-snapshot-browse-modal')).show();
|
||||
|
||||
// Fetch snapshot content via AJAX
|
||||
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||
@@ -617,16 +622,6 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
||||
}
|
||||
|
||||
// Close modals
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-modal-close') ||
|
||||
e.target.id === 'mb-snapshot-create-modal' ||
|
||||
e.target.id === 'mb-snapshot-restore-modal' ||
|
||||
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';
|
||||
}
|
||||
});
|
||||
// Modal close handled by Bootstrap data-bs-dismiss
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokosuitebackup_cpanel</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -20,6 +20,7 @@
|
||||
<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>
|
||||
|
||||
@@ -120,7 +120,7 @@ $moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
|
||||
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 htmlspecialchars($profile->title); ?>
|
||||
#<?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; ?>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.39.01</version>
|
||||
<version>01.43.12</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user