Compare commits
34 Commits
version/01.32.00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7515274712 | |||
| 0be459fe34 | |||
| 11ccdbfde4 | |||
| fd517c16f3 | |||
| fe76f81b47 | |||
| 18127454b5 | |||
| 7826c315b1 | |||
| e329dbd99b | |||
| d6b3e8cff0 | |||
| 80c97620a5 | |||
| 33d852bacf | |||
| 8be0500913 | |||
| 27dded6c62 | |||
| e465dfa6ee | |||
| 3ac0318ba3 | |||
| 17e4625448 | |||
| eb748323f7 | |||
| bc3085f74b | |||
| f66100f74f | |||
| be8b1f73bf | |||
| 0f2c4fc238 | |||
| d0fe641d5c | |||
| 4a2520a43b | |||
| 54c3a6e2e9 | |||
| a27ec0f0b9 | |||
| a7c30ad67c | |||
| ee21f7a373 | |||
| 5c0ff72d27 | |||
| 50c016d707 | |||
| e4de103a00 | |||
| 8c66fd3260 | |||
| 4213def0ad | |||
| 8a4ebe1bde | |||
| 8ea09ee0d1 |
@@ -1,66 +1,66 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
# VERSION: 09.02.00
|
# VERSION: 09.02.00
|
||||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||||
|
|
||||||
name: "Universal: Auto Version Bump"
|
name: "Universal: Auto Version Bump"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- rc
|
- rc
|
||||||
- 'feature/**'
|
- 'feature/**'
|
||||||
- 'patch/**'
|
- 'patch/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump:
|
bump:
|
||||||
name: Version Bump
|
name: Version Bump
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
run: |
|
run: |
|
||||||
if ! command -v composer &> /dev/null; then
|
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
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
if [ -d "/opt/mokocli/cli" ]; then
|
if [ -d "/opt/mokocli/cli" ]; then
|
||||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||||
/tmp/mokocli
|
/tmp/mokocli
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
run: |
|
run: |
|
||||||
php ${MOKO_CLI}/version_auto_bump.php \
|
php ${MOKO_CLI}/version_auto_bump.php \
|
||||||
--path . --branch "${GITHUB_REF_NAME}" \
|
--path . --branch "${GITHUB_REF_NAME}" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.32.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
+534
-534
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+12
-25
@@ -1,31 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.32.00] --- 2026-06-22
|
## [01.35.03] --- 2026-06-23
|
||||||
|
|
||||||
## [01.32.00] --- 2026-06-22
|
## [01.35.03] --- 2026-06-23
|
||||||
|
|
||||||
|
## [01.35.01] --- 2026-06-23
|
||||||
|
|
||||||
|
## [01.35.01] --- 2026-06-23
|
||||||
|
|
||||||
|
## [01.35.00] --- 2026-06-23
|
||||||
|
|
||||||
|
## [01.35.00] --- 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62)
|
- SFTP remote storage with SSH key file authentication — key stored securely in database
|
||||||
- Email/ntfy notifications for site restores and snapshot create/restore operations (#60)
|
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
|
||||||
- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56)
|
|
||||||
|
|
||||||
## [01.31.00] --- 2026-06-22
|
|
||||||
|
|
||||||
## [01.31.00] --- 2026-06-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
|
|
||||||
- Automatic archive integrity verification after backup creation (#65)
|
|
||||||
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
|
|
||||||
|
|
||||||
## [01.30.00] --- 2026-06-22
|
|
||||||
|
|
||||||
## [01.30.00] --- 2026-06-22
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
|
|
||||||
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MokoSuiteBackup
|
# MokoSuiteBackup
|
||||||
|
|
||||||
<!-- VERSION: 01.32.00 -->
|
<!-- VERSION: 01.35.03 -->
|
||||||
|
|
||||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class BackupsController extends ApiController
|
|||||||
// Strip sensitive credentials before serialization
|
// Strip sensitive credentials before serialization
|
||||||
$sensitiveFields = [
|
$sensitiveFields = [
|
||||||
'ftp_password', 'ftp_username',
|
'ftp_password', 'ftp_username',
|
||||||
|
'sftp_password', 'sftp_key_data', 'sftp_passphrase',
|
||||||
's3_access_key', 's3_secret_key',
|
's3_access_key', 's3_secret_key',
|
||||||
'gdrive_client_secret', 'gdrive_refresh_token',
|
'gdrive_client_secret', 'gdrive_refresh_token',
|
||||||
'encryption_password', 'ntfy_token',
|
'encryption_password', 'ntfy_token',
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
default="none"
|
default="none"
|
||||||
>
|
>
|
||||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||||
</field>
|
</field>
|
||||||
@@ -174,6 +174,85 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
|
<!-- SFTP fields (shown when remote_storage = sftp) -->
|
||||||
|
<field
|
||||||
|
name="sftp_host"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
required="true"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_port"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
|
||||||
|
default="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_username"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
required="true"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_auth_type"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
|
||||||
|
default="key"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
>
|
||||||
|
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
|
||||||
|
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
|
||||||
|
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sftp_password"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
required="true"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:password"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_key_data"
|
||||||
|
type="SshKey"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
||||||
|
filter="raw"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
|
||||||
|
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_passphrase"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
required="true"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_path"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
|
||||||
|
default="/backups"
|
||||||
|
maxlength="512"
|
||||||
|
required="true"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
|
|||||||
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
|
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS="Content Snapshots"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL="View All"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT="Latest"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS="No snapshots yet. Create one from the Content Snapshots view."
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
|
||||||
|
|
||||||
; Backups view
|
; Backups view
|
||||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
||||||
@@ -44,6 +50,22 @@ COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
|||||||
; Backup detail view
|
; Backup detail view
|
||||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||||
|
; Backup comparison
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||||
@@ -220,7 +242,35 @@ COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been mo
|
|||||||
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
||||||
|
|
||||||
; S3 storage
|
; S3 storage
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)"
|
||||||
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
||||||
|
|
||||||
|
; SFTP fields
|
||||||
|
COM_MOKOJOOMBACKUP_FIELDSET_SFTP="SFTP Settings"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST="SFTP Host"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC="SFTP server hostname or IP address"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT="SFTP Port"
|
||||||
|
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_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_UPLOAD="Upload Key File"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC="Directory on the remote server to upload backups to"
|
||||||
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
|
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data"
|
|||||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
|
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||||
|
; Backup comparison
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<extension type="component" method="upgrade">
|
||||||
<name>MokoSuiteBackup</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||||
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||||
|
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||||
|
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_key_data` MEDIUMTEXT,
|
||||||
|
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||||
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
|
||||||
|
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
|
||||||
|
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
|
||||||
|
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
|
||||||
|
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
|
||||||
|
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
|
||||||
|
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
|
||||||
@@ -377,6 +377,391 @@ class AjaxController extends BaseController
|
|||||||
$this->sendJson($result);
|
$this->sendJson($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse archive contents without extracting.
|
||||||
|
* POST: task=ajax.browseArchive&id=123
|
||||||
|
*/
|
||||||
|
public function browseArchive(): 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('id', 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete' || !$record->filesexist) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$archivePath = $record->absolute_path;
|
||||||
|
|
||||||
|
if (!is_file($archivePath)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxEntries = 500;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$files = [];
|
||||||
|
$totalFiles = 0;
|
||||||
|
$totalSize = 0;
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
$lower = strtolower($archivePath);
|
||||||
|
|
||||||
|
if (substr($lower, -4) === '.zip') {
|
||||||
|
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||||
|
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
|
||||||
|
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||||
|
} else {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'files' => $files,
|
||||||
|
'total_files' => $totalFiles,
|
||||||
|
'total_size' => $totalSize,
|
||||||
|
'truncated' => $truncated,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse a ZIP archive and return file entries.
|
||||||
|
*
|
||||||
|
* @param string $path Absolute path to the ZIP file
|
||||||
|
* @param int $maxEntries Maximum entries to return
|
||||||
|
* @param int &$totalFiles Total number of files (by reference)
|
||||||
|
* @param int &$totalSize Total uncompressed size (by reference)
|
||||||
|
* @param bool &$truncated Whether results were truncated (by reference)
|
||||||
|
*
|
||||||
|
* @return array List of file entry arrays
|
||||||
|
*/
|
||||||
|
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||||
|
{
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
|
||||||
|
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
|
||||||
|
throw new \RuntimeException('Cannot open ZIP archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$totalFiles = $zip->numFiles;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $totalFiles; $i++) {
|
||||||
|
$stat = $zip->statIndex($i);
|
||||||
|
|
||||||
|
if ($stat === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalSize += $stat['size'];
|
||||||
|
|
||||||
|
if (\count($files) < $maxEntries) {
|
||||||
|
$files[] = [
|
||||||
|
'name' => $stat['name'],
|
||||||
|
'size' => $stat['size'],
|
||||||
|
'compressed_size' => $stat['comp_size'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = $totalFiles > $maxEntries;
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse a tar.gz archive and return file entries.
|
||||||
|
*
|
||||||
|
* @param string $path Absolute path to the tar.gz file
|
||||||
|
* @param int $maxEntries Maximum entries to return
|
||||||
|
* @param int &$totalFiles Total number of files (by reference)
|
||||||
|
* @param int &$totalSize Total uncompressed size (by reference)
|
||||||
|
* @param bool &$truncated Whether results were truncated (by reference)
|
||||||
|
*
|
||||||
|
* @return array List of file entry arrays
|
||||||
|
*/
|
||||||
|
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||||
|
{
|
||||||
|
$phar = new \PharData($path);
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
||||||
|
$totalFiles++;
|
||||||
|
$entrySize = $entry->getSize();
|
||||||
|
$totalSize += $entrySize;
|
||||||
|
|
||||||
|
if (\count($files) < $maxEntries) {
|
||||||
|
// Strip the phar:// prefix and archive path to get relative name
|
||||||
|
$fullPath = str_replace('\\', '/', $entry->getPathname());
|
||||||
|
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
|
||||||
|
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
|
||||||
|
?: $fullPath;
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'name' => $relativeName,
|
||||||
|
'size' => $entrySize,
|
||||||
|
'compressed_size' => $entrySize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = $totalFiles > $maxEntries;
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse articles inside a snapshot — returns JSON list for the browse modal.
|
||||||
|
* POST: task=ajax.browseSnapshot&id=123
|
||||||
|
*/
|
||||||
|
public function browseSnapshot(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('id', 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete') {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = file_get_contents($record->data_file);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$articles = [];
|
||||||
|
|
||||||
|
foreach ($data['tables']['#__content'] as $row) {
|
||||||
|
$articles[] = [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'title' => $row['title'] ?? '',
|
||||||
|
'catid' => (int) ($row['catid'] ?? 0),
|
||||||
|
'state' => (int) ($row['state'] ?? 0),
|
||||||
|
'created' => $row['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'articles' => $articles,
|
||||||
|
'total' => count($articles),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two backup records side-by-side.
|
||||||
|
* POST: task=ajax.compareBackups&id1=123&id2=456
|
||||||
|
*/
|
||||||
|
public function compareBackups(): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id1 = $this->input->getInt('id1', 0);
|
||||||
|
$id2 = $this->input->getInt('id2', 0);
|
||||||
|
|
||||||
|
if (!$id1 || !$id2) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id1 === $id2) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'r.id', 'r.description', 'r.status', 'r.backup_type',
|
||||||
|
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
|
||||||
|
'r.backupstart', 'r.backupend',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName($fields))
|
||||||
|
->select($db->quoteName('p.title', 'profile_title'))
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
|
||||||
|
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
|
||||||
|
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$rows = $db->loadObjectList('id');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($rows[$id1])) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($rows[$id2])) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$b1 = $rows[$id1];
|
||||||
|
$b2 = $rows[$id2];
|
||||||
|
|
||||||
|
// Calculate durations in seconds
|
||||||
|
$duration1 = 0;
|
||||||
|
$duration2 = 0;
|
||||||
|
|
||||||
|
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
|
||||||
|
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
|
||||||
|
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatRecord = function ($row) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'description' => $row->description,
|
||||||
|
'status' => $row->status,
|
||||||
|
'backup_type' => $row->backup_type,
|
||||||
|
'total_size' => (int) $row->total_size,
|
||||||
|
'db_size' => (int) $row->db_size,
|
||||||
|
'files_count' => (int) $row->files_count,
|
||||||
|
'tables_count' => (int) $row->tables_count,
|
||||||
|
'backupstart' => $row->backupstart,
|
||||||
|
'backupend' => $row->backupend,
|
||||||
|
'profile_title' => $row->profile_title ?? '',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'backup1' => $formatRecord($b1),
|
||||||
|
'backup2' => $formatRecord($b2),
|
||||||
|
'delta' => [
|
||||||
|
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
|
||||||
|
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
|
||||||
|
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
|
||||||
|
'duration_diff_seconds' => $duration2 - $duration1,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a JSON response and close the application.
|
* Send a JSON response and close the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Joomla\CMS\Factory;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
use Joomla\CMS\MVC\Controller\AdminController;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
||||||
|
|
||||||
@@ -106,6 +107,151 @@ class SnapshotsController extends AdminController
|
|||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse articles inside a snapshot — returns JSON for AJAX modal.
|
||||||
|
*/
|
||||||
|
public function browse(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('id', 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete') {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = file_get_contents($record->data_file);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$articles = [];
|
||||||
|
|
||||||
|
foreach ($data['tables']['#__content'] as $row) {
|
||||||
|
$articles[] = [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'title' => $row['title'] ?? '',
|
||||||
|
'catid' => (int) ($row['catid'] ?? 0),
|
||||||
|
'state' => (int) ($row['state'] ?? 0),
|
||||||
|
'created' => $row['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'articles' => $articles,
|
||||||
|
'total' => count($articles),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore selected articles from a snapshot.
|
||||||
|
*/
|
||||||
|
public function restoreSelected(): void
|
||||||
|
{
|
||||||
|
$this->checkToken();
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('id', 0);
|
||||||
|
$articleIds = $this->input->get('article_ids', [], 'array');
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$engine = new SnapshotRestoreEngine();
|
||||||
|
$result = $engine->restoreSelectedArticles($id, $articleIds);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$this->setMessage($result['message']);
|
||||||
|
} else {
|
||||||
|
$this->setMessage($result['message'], 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON response and close the application.
|
||||||
|
*/
|
||||||
|
private function sendJson(array $data, int $status = 200): void
|
||||||
|
{
|
||||||
|
$app = $this->app;
|
||||||
|
$app->setHeader('status', $status);
|
||||||
|
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
$app->sendHeaders();
|
||||||
|
|
||||||
|
echo json_encode($data);
|
||||||
|
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete snapshot records and their data files.
|
* Delete snapshot records and their data files.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ class BackupEngine
|
|||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
'ftp' => new FtpUploader($profile),
|
'ftp' => new FtpUploader($profile),
|
||||||
|
'sftp' => new SftpUploader($profile),
|
||||||
'google_drive' => new GoogleDriveUploader($profile),
|
'google_drive' => new GoogleDriveUploader($profile),
|
||||||
's3' => new S3Uploader($profile),
|
's3' => new S3Uploader($profile),
|
||||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
|
|||||||
@@ -278,6 +278,21 @@ class PreflightCheck
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'sftp':
|
||||||
|
if (empty($profile->sftp_host)) {
|
||||||
|
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($profile->sftp_username)) {
|
||||||
|
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
||||||
|
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
case 'google_drive':
|
case 'google_drive':
|
||||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*
|
||||||
|
* SFTP uploader using the system sftp/scp binary with SSH key authentication.
|
||||||
|
*
|
||||||
|
* The private key is stored in the database (profile column) and written
|
||||||
|
* to a temp file with 0600 permissions at upload time, then deleted.
|
||||||
|
* This avoids leaving key files on the filesystem permanently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
class SftpUploader implements RemoteUploaderInterface
|
||||||
|
{
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $username;
|
||||||
|
private string $keyData;
|
||||||
|
private string $passphrase;
|
||||||
|
private string $password;
|
||||||
|
private string $remotePath;
|
||||||
|
|
||||||
|
public function __construct(object $profile)
|
||||||
|
{
|
||||||
|
$this->host = $profile->sftp_host ?? '';
|
||||||
|
$this->port = (int) ($profile->sftp_port ?? 22);
|
||||||
|
$this->username = $profile->sftp_username ?? '';
|
||||||
|
$this->keyData = $profile->sftp_key_data ?? '';
|
||||||
|
$this->passphrase = $profile->sftp_passphrase ?? '';
|
||||||
|
$this->password = $profile->sftp_password ?? '';
|
||||||
|
$this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(string $localPath, string $remoteName): array
|
||||||
|
{
|
||||||
|
if (empty($this->host)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->username)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP username is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->keyData) && empty($this->password)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP requires either a private key or password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* Write key to temp file if using key auth */
|
||||||
|
if (!empty($this->keyData)) {
|
||||||
|
$keyFile = $this->writeTempKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure remote directory exists */
|
||||||
|
$this->ensureRemoteDir($keyFile);
|
||||||
|
|
||||||
|
/* Upload via scp */
|
||||||
|
$remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName;
|
||||||
|
$cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
$errorMsg = implode("\n", $output);
|
||||||
|
throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify upload by checking remote file size */
|
||||||
|
$remoteFile = $this->remotePath . '/' . $remoteName;
|
||||||
|
$remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile);
|
||||||
|
$localSize = filesize($localPath);
|
||||||
|
|
||||||
|
if ($remoteSize > 0 && $remoteSize !== $localSize) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Uploaded via SFTP: ' . $remoteFile,
|
||||||
|
'remote_path' => $remoteFile,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()];
|
||||||
|
} finally {
|
||||||
|
$this->cleanupTempKey($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
if (empty($this->host)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!empty($this->keyData)) {
|
||||||
|
$keyFile = $this->writeTempKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile);
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
|
||||||
|
} finally {
|
||||||
|
$this->cleanupTempKey($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the private key from the database to a temp file with 0600 permissions.
|
||||||
|
*/
|
||||||
|
private function writeTempKey(): string
|
||||||
|
{
|
||||||
|
$tmpDir = sys_get_temp_dir();
|
||||||
|
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
||||||
|
|
||||||
|
/* Key is stored base64-encoded in the database — decode before writing */
|
||||||
|
$keyContent = base64_decode($this->keyData, true);
|
||||||
|
|
||||||
|
if ($keyContent === false) {
|
||||||
|
/* Fallback: might be raw PEM (legacy or paste) */
|
||||||
|
$keyContent = $this->keyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||||
|
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||||
|
}
|
||||||
|
|
||||||
|
chmod($keyFile, 0600);
|
||||||
|
|
||||||
|
return $keyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the temp key file.
|
||||||
|
*/
|
||||||
|
private function cleanupTempKey(?string $keyFile): void
|
||||||
|
{
|
||||||
|
if ($keyFile !== null && is_file($keyFile)) {
|
||||||
|
unlink($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the remote directory exists via ssh mkdir -p.
|
||||||
|
*/
|
||||||
|
private function ensureRemoteDir(?string $keyFile): void
|
||||||
|
{
|
||||||
|
$escapedPath = escapeshellarg($this->remotePath);
|
||||||
|
$cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
/* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote file size via ssh stat.
|
||||||
|
*/
|
||||||
|
private function getRemoteFileSize(string $remotePath, ?string $keyFile): int
|
||||||
|
{
|
||||||
|
$escapedPath = escapeshellarg($remotePath);
|
||||||
|
$cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
exec($cmd . ' 2>&1', $output);
|
||||||
|
|
||||||
|
$size = (int) trim(implode('', $output));
|
||||||
|
|
||||||
|
return $size > 0 ? $size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an scp command string with proper SSH options.
|
||||||
|
*/
|
||||||
|
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
|
||||||
|
{
|
||||||
|
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||||
|
|
||||||
|
if ($this->port !== 22) {
|
||||||
|
$parts[] = '-P';
|
||||||
|
$parts[] = (string) $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyFile !== null) {
|
||||||
|
$parts[] = '-i';
|
||||||
|
$parts[] = escapeshellarg($keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->passphrase)) {
|
||||||
|
/* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect.
|
||||||
|
For now, key files should be unencrypted or use ssh-agent. */
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($localPath);
|
||||||
|
$parts[] = escapeshellarg($remoteTarget);
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an ssh command string for remote commands.
|
||||||
|
*/
|
||||||
|
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
|
||||||
|
{
|
||||||
|
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||||
|
|
||||||
|
if ($this->port !== 22) {
|
||||||
|
$parts[] = '-p';
|
||||||
|
$parts[] = (string) $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyFile !== null) {
|
||||||
|
$parts[] = '-i';
|
||||||
|
$parts[] = escapeshellarg($keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($this->username . '@' . $this->host);
|
||||||
|
$parts[] = escapeshellarg($remoteCmd);
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -386,6 +386,181 @@ class SnapshotRestoreEngine
|
|||||||
return array_unique($tables);
|
return array_unique($tables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore only selected articles (and their related rows) from a snapshot.
|
||||||
|
*
|
||||||
|
* Uses merge/upsert mode: updates existing rows by ID, inserts missing ones.
|
||||||
|
*
|
||||||
|
* @param int $snapshotId Snapshot record ID
|
||||||
|
* @param array $articleIds Article IDs to restore
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string, restored?: int, log?: string}
|
||||||
|
*/
|
||||||
|
public function restoreSelectedArticles(int $snapshotId, array $articleIds): array
|
||||||
|
{
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
return ['success' => false, 'message' => 'No article IDs provided'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$articleIds = array_map('intval', $articleIds);
|
||||||
|
$articleIds = array_filter($articleIds, fn($id) => $id > 0);
|
||||||
|
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
return ['success' => false, 'message' => 'No valid article IDs provided'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Load snapshot record
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $snapshotId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete') {
|
||||||
|
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('Loading snapshot file: ' . basename($record->data_file));
|
||||||
|
|
||||||
|
$json = file_get_contents($record->data_file);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
return ['success' => false, 'message' => 'Cannot read snapshot file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || empty($data['tables'])) {
|
||||||
|
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentTable = $data['tables']['#__content'] ?? [];
|
||||||
|
|
||||||
|
if (empty($contentTable)) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot does not contain articles'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter #__content rows to only selected article IDs
|
||||||
|
$selectedRows = array_filter($contentTable, fn($row) => in_array((int) ($row['id'] ?? 0), $articleIds, true));
|
||||||
|
|
||||||
|
if (empty($selectedRows)) {
|
||||||
|
return ['success' => false, 'message' => 'None of the selected article IDs exist in this snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$foundIds = array_map(fn($row) => (int) $row['id'], $selectedRows);
|
||||||
|
$this->log('Restoring ' . count($selectedRows) . ' articles: IDs ' . implode(', ', $foundIds));
|
||||||
|
|
||||||
|
// Filter workflow_associations for selected articles
|
||||||
|
$workflowRows = [];
|
||||||
|
|
||||||
|
if (!empty($data['tables']['#__workflow_associations'])) {
|
||||||
|
$workflowRows = array_filter(
|
||||||
|
$data['tables']['#__workflow_associations'],
|
||||||
|
fn($row) => in_array((int) ($row['item_id'] ?? 0), $foundIds, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tag_map entries for selected articles
|
||||||
|
$tagMapRows = [];
|
||||||
|
|
||||||
|
if (!empty($data['tables']['#__contentitem_tag_map'])) {
|
||||||
|
$tagMapRows = array_filter(
|
||||||
|
$data['tables']['#__contentitem_tag_map'],
|
||||||
|
fn($row) => in_array((int) ($row['content_item_id'] ?? 0), $foundIds, true)
|
||||||
|
&& str_starts_with($row['type_alias'] ?? '', 'com_content.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $db->getPrefix();
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->transactionStart();
|
||||||
|
|
||||||
|
// Restore articles using merge/upsert
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__content');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__content', array_values($selectedRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__content: ' . $rowCount . ' rows restored');
|
||||||
|
|
||||||
|
// Restore workflow associations
|
||||||
|
if (!empty($workflowRows)) {
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__workflow_associations');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__workflow_associations', array_values($workflowRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__workflow_associations: ' . $rowCount . ' rows restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore tag map entries
|
||||||
|
if (!empty($tagMapRows)) {
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__contentitem_tag_map');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__contentitem_tag_map', array_values($tagMapRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__contentitem_tag_map: ' . $rowCount . ' rows restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->transactionCommit();
|
||||||
|
|
||||||
|
$this->log('Selective restore complete: ' . $totalRows . ' total rows');
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
try {
|
||||||
|
$profile = NotificationSender::getDefaultProfile();
|
||||||
|
|
||||||
|
if ($profile) {
|
||||||
|
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
|
||||||
|
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
|
||||||
|
|
||||||
|
NotificationSender::sendRestoreNotification($profile, 'snapshot_selective_restore', [
|
||||||
|
'mode' => 'selective',
|
||||||
|
'article_ids' => $foundIds,
|
||||||
|
'row_count' => $totalRows,
|
||||||
|
'user' => $userName . ' (ID: ' . $userIdVal . ')',
|
||||||
|
], implode("\n", $this->log));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||||
|
'restored' => count($selectedRows),
|
||||||
|
'log' => implode("\n", $this->log),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try {
|
||||||
|
$db->transactionRollback();
|
||||||
|
$this->log('Transaction rolled back');
|
||||||
|
} catch (\Exception $rollbackEx) {
|
||||||
|
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||||
|
'log' => implode("\n", $this->log),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$uploader = match ($session->remoteStorage) {
|
$uploader = match ($session->remoteStorage) {
|
||||||
'ftp' => new FtpUploader($profile),
|
'ftp' => new FtpUploader($profile),
|
||||||
|
'sftp' => new SftpUploader($profile),
|
||||||
'google_drive' => new GoogleDriveUploader($profile),
|
'google_drive' => new GoogleDriveUploader($profile),
|
||||||
's3' => new S3Uploader($profile),
|
's3' => new S3Uploader($profile),
|
||||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteBackup
|
||||||
|
* @subpackage com_mokosuitebackup
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*
|
||||||
|
* Custom field for SSH private key input.
|
||||||
|
* Supports both file upload (via FileReader JS) and paste-in textarea.
|
||||||
|
* The key content is stored in the database, not as a file on disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
|
||||||
|
class SshKeyField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'SshKey';
|
||||||
|
|
||||||
|
protected function getInput(): string
|
||||||
|
{
|
||||||
|
$value = $this->value ?? '';
|
||||||
|
$id = $this->id;
|
||||||
|
$name = $this->name;
|
||||||
|
|
||||||
|
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
|
||||||
|
|
||||||
|
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<span class="badge bg-success me-2">'
|
||||||
|
. '<span class="icon-lock" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File upload button */
|
||||||
|
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
|
||||||
|
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
|
||||||
|
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
|
||||||
|
$html .= '</label>';
|
||||||
|
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
|
||||||
|
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
|
||||||
|
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
|
||||||
|
|
||||||
|
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
|
||||||
|
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
|
||||||
|
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
|
||||||
|
. '<span class="icon-times" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
|
||||||
|
. '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden field — key data is NEVER rendered as visible text.
|
||||||
|
On existing keys, we submit a sentinel value to preserve the DB value
|
||||||
|
unless a new file is uploaded or clear is clicked. */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="__KEEP_EXISTING__">';
|
||||||
|
} else {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="">';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= $this->getScript();
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getScript(): string
|
||||||
|
{
|
||||||
|
return <<<'JS'
|
||||||
|
<script>
|
||||||
|
function mokoSshKeyFileSelected(fieldId, input) {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
var file = input.files[0];
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
/* Base64 encode the key before storing in the hidden field */
|
||||||
|
var content = e.target.result;
|
||||||
|
var encoded = btoa(content);
|
||||||
|
document.getElementById(fieldId).value = encoded;
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = file.name + ' uploaded';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mokoSshKeyClear(fieldId) {
|
||||||
|
document.getElementById(fieldId).value = '';
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = 'Key removed';
|
||||||
|
var fileInput = document.getElementById(fieldId + '-file');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,90 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest snapshot info for the dashboard widget.
|
||||||
|
*/
|
||||||
|
public function getLatestSnapshot(): ?object
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
|
||||||
|
return $db->loadObject() ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshot count.
|
||||||
|
*/
|
||||||
|
public function getSnapshotCount(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get backup size trend data for the last 30 days.
|
||||||
|
* Returns array of {date, total_size, count, status} grouped by day.
|
||||||
|
*/
|
||||||
|
public function getBackupTrend(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$cutoff = date('Y-m-d', strtotime('-30 days'));
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('DATE(' . $db->quoteName('backupstart') . ') AS backup_date')
|
||||||
|
->select('SUM(' . $db->quoteName('total_size') . ') AS day_size')
|
||||||
|
->select('COUNT(*) AS day_count')
|
||||||
|
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS fail_count')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
|
->where('DATE(' . $db->quoteName('backupstart') . ') >= ' . $db->quote($cutoff))
|
||||||
|
->group('DATE(' . $db->quoteName('backupstart') . ')')
|
||||||
|
->order('backup_date ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage breakdown by profile.
|
||||||
|
*/
|
||||||
|
public function getStorageByProfile(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('p.title AS profile_title')
|
||||||
|
->select('COUNT(*) AS backup_count')
|
||||||
|
->select('COALESCE(SUM(r.total_size), 0) AS total_size')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
||||||
|
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
|
||||||
|
->group($db->quoteName('r.profile_id'))
|
||||||
|
->order('total_size DESC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get published backup profiles for the quick-action selector.
|
* Get published backup profiles for the quick-action selector.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ class ProfileTable extends Table
|
|||||||
|
|
||||||
public function store($updateNulls = true): bool
|
public function store($updateNulls = true): bool
|
||||||
{
|
{
|
||||||
|
/* Handle SSH key sentinel — when __KEEP_EXISTING__ is submitted,
|
||||||
|
preserve the current DB value instead of overwriting with the sentinel.
|
||||||
|
This prevents the key from being exposed in the form HTML. */
|
||||||
|
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
|
||||||
|
if ($this->id) {
|
||||||
|
$db = $this->getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('sftp_key_data'))
|
||||||
|
->from($db->quoteName($this->_tbl))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $this->id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->sftp_key_data = $db->loadResult() ?: '';
|
||||||
|
} else {
|
||||||
|
$this->sftp_key_data = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = parent::store($updateNulls);
|
$result = parent::store($updateNulls);
|
||||||
|
|
||||||
if ($result && !empty($this->backup_dir)) {
|
if ($result && !empty($this->backup_dir)) {
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
||||||
|
|
||||||
|
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,26 @@ class HtmlView extends BaseHtmlView
|
|||||||
public array $systemHealth = [];
|
public array $systemHealth = [];
|
||||||
public array $profiles = [];
|
public array $profiles = [];
|
||||||
public bool $defaultDirWarning = false;
|
public bool $defaultDirWarning = false;
|
||||||
|
public ?object $latestSnapshot = null;
|
||||||
|
public int $snapshotCount = 0;
|
||||||
|
public array $backupTrend = [];
|
||||||
|
public array $storageByProfile = [];
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
|
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
|
||||||
$model = $this->getModel();
|
$model = $this->getModel();
|
||||||
|
|
||||||
$this->lastBackup = $model->getLastBackup();
|
$this->lastBackup = $model->getLastBackup();
|
||||||
$this->nextScheduled = $model->getNextScheduled();
|
$this->nextScheduled = $model->getNextScheduled();
|
||||||
$this->stats = $model->getStats();
|
$this->stats = $model->getStats();
|
||||||
$this->systemHealth = $model->getSystemHealth();
|
$this->systemHealth = $model->getSystemHealth();
|
||||||
$this->profiles = $model->getProfiles();
|
$this->profiles = $model->getProfiles();
|
||||||
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
|
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
|
||||||
|
$this->latestSnapshot = $model->getLatestSnapshot();
|
||||||
|
$this->snapshotCount = $model->getSnapshotCount();
|
||||||
|
$this->backupTrend = $model->getBackupTrend();
|
||||||
|
$this->storageByProfile = $model->getStorageByProfile();
|
||||||
|
|
||||||
$this->addToolbar();
|
$this->addToolbar();
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
|
||||||
|
<!-- Archive Browser -->
|
||||||
|
<h4 class="mt-4">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
|
||||||
|
</h4>
|
||||||
|
<div id="mb-detail-browse" class="bg-light rounded" style="max-height:400px; overflow-y:auto;">
|
||||||
|
<div id="mb-detail-browse-summary" class="p-2 text-muted" style="font-size:0.85rem;"></div>
|
||||||
|
<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-detail-browse-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Backup Log -->
|
<!-- Backup Log -->
|
||||||
<h4 class="mt-4"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
|
<h4 class="mt-4"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
|
||||||
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
|
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
|
||||||
@@ -104,22 +126,105 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var form = new URLSearchParams();
|
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
form.append('task', 'ajax.viewLog');
|
var TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
||||||
form.append('id', <?php echo (int) $this->item->id; ?>);
|
|
||||||
form.append(<?php echo json_encode($ajaxToken); ?>, '1');
|
|
||||||
|
|
||||||
fetch(<?php echo json_encode($ajaxUrl); ?>, {
|
function postAjax(params) {
|
||||||
method: 'POST',
|
var form = new URLSearchParams();
|
||||||
body: form,
|
form.append(TOKEN_NAME, '1');
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
for (var k in params) {
|
||||||
})
|
if (params.hasOwnProperty(k)) {
|
||||||
.then(function(r) { return r.json(); })
|
form.append(k, params[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fetch(AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
}).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load log
|
||||||
|
postAjax({ task: 'ajax.viewLog', id: <?php echo (int) $this->item->id; ?> })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
|
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
|
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
|
||||||
|
// Load archive contents
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
var browseTbody = document.getElementById('mb-detail-browse-tbody');
|
||||||
|
var browseSummary = document.getElementById('mb-detail-browse-summary');
|
||||||
|
browseSetMessage(browseTbody, 'Loading...');
|
||||||
|
|
||||||
|
postAjax({ task: 'ajax.browseArchive', id: <?php echo (int) $this->item->id; ?> })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
browseSetMessage(browseTbody, data.message || 'Error', 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
browseTbody.textContent = '';
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
browseSetMessage(browseTbody, 'Archive is empty', 'text-center text-muted');
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < data.files.length; i++) {
|
||||||
|
browseAddFileRow(browseTbody, data.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
|
||||||
|
if (data.truncated) {
|
||||||
|
text += ' (showing first ' + data.files.length + ')';
|
||||||
|
}
|
||||||
|
browseSummary.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
browseSetMessage(browseTbody, 'Error: ' + err.message, 'text-danger');
|
||||||
|
});
|
||||||
|
<?php endif; ?>
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</span>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?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"
|
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
|
||||||
@@ -485,6 +492,93 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
document.getElementById('mb-log-modal').style.display = 'none';
|
document.getElementById('mb-log-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Browse Archive modal handler
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseSetMessage(tbody, message, cssClass) {
|
||||||
|
tbody.textContent = '';
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', '3');
|
||||||
|
td.className = cssClass || 'text-center';
|
||||||
|
td.textContent = message;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseAddFileRow(tbody, file) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdName = document.createElement('td');
|
||||||
|
tdName.style.wordBreak = 'break-all';
|
||||||
|
tdName.style.fontSize = '0.85rem';
|
||||||
|
var code = document.createElement('code');
|
||||||
|
code.textContent = file.name;
|
||||||
|
tdName.appendChild(code);
|
||||||
|
tr.appendChild(tdName);
|
||||||
|
|
||||||
|
var tdSize = document.createElement('td');
|
||||||
|
tdSize.className = 'text-end text-nowrap';
|
||||||
|
tdSize.textContent = formatFileSize(file.size);
|
||||||
|
tr.appendChild(tdSize);
|
||||||
|
|
||||||
|
var tdComp = document.createElement('td');
|
||||||
|
tdComp.className = 'text-end text-nowrap';
|
||||||
|
tdComp.textContent = formatFileSize(file.compressed_size);
|
||||||
|
tr.appendChild(tdComp);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.mb-browse-archive');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var recordId = btn.getAttribute('data-id');
|
||||||
|
var modal = document.getElementById('mb-browse-modal');
|
||||||
|
var tbody = document.getElementById('mb-browse-tbody');
|
||||||
|
var summary = document.getElementById('mb-browse-summary');
|
||||||
|
browseSetMessage(tbody, 'Loading...');
|
||||||
|
summary.textContent = '';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
postAjax({ task: 'ajax.browseArchive', id: recordId })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
browseSetMessage(tbody, data.message || 'Error', 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.textContent = '';
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
browseSetMessage(tbody, 'Archive is empty', 'text-center text-muted');
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < data.files.length; i++) {
|
||||||
|
browseAddFileRow(tbody, data.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
|
||||||
|
if (data.truncated) {
|
||||||
|
text += ' (showing first ' + data.files.length + ')';
|
||||||
|
}
|
||||||
|
summary.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
browseSetMessage(tbody, 'Error: ' + err.message, 'text-danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.id === 'mb-browse-modal' || e.target.classList.contains('mb-browse-close')) {
|
||||||
|
document.getElementById('mb-browse-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -567,3 +661,201 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive Browser Modal -->
|
||||||
|
<div id="mb-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var COMPARE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
|
var COMPARE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||||
|
|
||||||
|
function mbCmpFormatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbCmpFormatDuration(seconds) {
|
||||||
|
if (seconds <= 0) return '0s';
|
||||||
|
var m = Math.floor(seconds / 60);
|
||||||
|
var s = seconds % 60;
|
||||||
|
return m > 0 ? m + 'm ' + s + 's' : s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbCmpDeltaCell(value, unit) {
|
||||||
|
if (value === 0) return '<span class="text-muted">—</span>';
|
||||||
|
var isPositive = value > 0;
|
||||||
|
var colorClass = isPositive ? 'text-danger' : 'text-success';
|
||||||
|
var display;
|
||||||
|
if (unit === 'bytes') {
|
||||||
|
display = (isPositive ? '+' : '') + mbCmpFormatBytes(value);
|
||||||
|
} else if (unit === 'duration') {
|
||||||
|
display = (isPositive ? '+' : '-') + mbCmpFormatDuration(Math.abs(value));
|
||||||
|
} else {
|
||||||
|
display = (isPositive ? '+' : '') + value.toLocaleString();
|
||||||
|
}
|
||||||
|
return '<span class="fw-bold ' + colorClass + '">' + display + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbShowCompareModal(id1, id2) {
|
||||||
|
var modal = document.getElementById('mb-compare-modal');
|
||||||
|
var loading = document.getElementById('mb-compare-loading');
|
||||||
|
var errorEl = document.getElementById('mb-compare-error');
|
||||||
|
var table = document.getElementById('mb-compare-table');
|
||||||
|
var body = document.getElementById('mb-compare-body');
|
||||||
|
|
||||||
|
modal.style.display = 'block';
|
||||||
|
loading.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
table.style.display = 'none';
|
||||||
|
body.innerHTML = '';
|
||||||
|
|
||||||
|
var form = new URLSearchParams();
|
||||||
|
form.append('task', 'ajax.compareBackups');
|
||||||
|
form.append('id1', id1);
|
||||||
|
form.append('id2', id2);
|
||||||
|
form.append(COMPARE_TOKEN, '1');
|
||||||
|
|
||||||
|
fetch(COMPARE_AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorEl.textContent = data.message || 'Error loading comparison';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var b1 = data.backup1;
|
||||||
|
var b2 = data.backup2;
|
||||||
|
var d = data.delta;
|
||||||
|
|
||||||
|
var dur1 = 0, dur2 = 0;
|
||||||
|
if (b1.backupstart !== '0000-00-00 00:00:00' && b1.backupend !== '0000-00-00 00:00:00') {
|
||||||
|
dur1 = (new Date(b1.backupend).getTime() - new Date(b1.backupstart).getTime()) / 1000;
|
||||||
|
}
|
||||||
|
if (b2.backupstart !== '0000-00-00 00:00:00' && b2.backupend !== '0000-00-00 00:00:00') {
|
||||||
|
dur2 = (new Date(b2.backupend).getTime() - new Date(b2.backupstart).getTime()) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = [
|
||||||
|
['<?php echo Text::_('JGRID_HEADING_ID', true); ?>', '#' + b1.id, '#' + b2.id, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', true); ?>', b1.description || '—', b2.description || '—', ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '—', b2.profile_title || '—', ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS', true); ?>', b1.status, b2.status, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE', true); ?>', b1.backup_type, b2.backup_type, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_SIZE', true); ?>', mbCmpFormatBytes(b1.total_size), mbCmpFormatBytes(b2.total_size), mbCmpDeltaCell(d.size_diff, 'bytes')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE', true); ?>', mbCmpFormatBytes(b1.db_size), mbCmpFormatBytes(b2.db_size), mbCmpDeltaCell(b2.db_size - b1.db_size, 'bytes')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT', true); ?>', b1.files_count.toLocaleString(), b2.files_count.toLocaleString(), mbCmpDeltaCell(d.files_diff, 'number')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT', true); ?>', b1.tables_count.toLocaleString(), b2.tables_count.toLocaleString(), mbCmpDeltaCell(d.tables_diff, 'number')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE', true); ?>', b1.backupstart, b2.backupstart, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DURATION', true); ?>', mbCmpFormatDuration(dur1), mbCmpFormatDuration(dur2), mbCmpDeltaCell(d.duration_diff_seconds, 'duration')],
|
||||||
|
];
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
html += '<tr><td class="fw-bold">' + rows[i][0] + '</td><td>' + rows[i][1] + '</td><td>' + rows[i][2] + '</td><td>' + rows[i][3] + '</td></tr>';
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
table.style.display = 'table';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
errorEl.textContent = 'Error: ' + err.message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept Compare toolbar button
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var compareBtn = document.querySelector('[onclick*="backups.compare"], .button-copy');
|
||||||
|
if (compareBtn) {
|
||||||
|
compareBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
||||||
|
if (checked.length !== 2) {
|
||||||
|
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO', true); ?>');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mbShowCompareModal(checked[0].value, checked[1].value);
|
||||||
|
return false;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -109,6 +109,122 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Row 1b: Snapshot Widget -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-camera" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS'); ?>
|
||||||
|
</h5>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($this->latestSnapshot) : ?>
|
||||||
|
<?php $types = json_decode($this->latestSnapshot->content_types, true) ?: []; ?>
|
||||||
|
<p class="mb-1">
|
||||||
|
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT'); ?>:</strong>
|
||||||
|
<?php echo $this->escape($this->latestSnapshot->description); ?>
|
||||||
|
</p>
|
||||||
|
<p class="mb-1 text-muted">
|
||||||
|
<?php echo HTMLHelper::_('date', $this->latestSnapshot->created, Text::_('DATE_FORMAT_LC4')); ?>
|
||||||
|
—
|
||||||
|
<?php foreach ($types as $type) : ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<small class="text-muted">
|
||||||
|
<?php echo (int) $this->latestSnapshot->articles_count; ?> articles,
|
||||||
|
<?php echo (int) $this->latestSnapshot->categories_count; ?> categories,
|
||||||
|
<?php echo (int) $this->latestSnapshot->modules_count; ?> modules
|
||||||
|
— <?php echo $this->snapshotCount; ?> total snapshots
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<?php else : ?>
|
||||||
|
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Breakdown by Profile -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN'); ?>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($this->storageByProfile)) : ?>
|
||||||
|
<?php
|
||||||
|
$maxSize = max(array_column($this->storageByProfile, 'total_size')) ?: 1;
|
||||||
|
$colors = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6f42c1', '#0dcaf0'];
|
||||||
|
?>
|
||||||
|
<?php foreach ($this->storageByProfile as $i => $profile) : ?>
|
||||||
|
<?php $pct = round(($profile->total_size / $maxSize) * 100); ?>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between small">
|
||||||
|
<span><?php echo $this->escape($profile->profile_title ?: 'Unknown'); ?> (<?php echo (int) $profile->backup_count; ?>)</span>
|
||||||
|
<span><?php echo HTMLHelper::_('number.bytes', $profile->total_size); ?></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e9ecef; border-radius:3px; height:8px; overflow:hidden;">
|
||||||
|
<div style="width:<?php echo $pct; ?>%; height:100%; background:<?php echo $colors[$i % count($colors)]; ?>; border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else : ?>
|
||||||
|
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Trend (30 days) -->
|
||||||
|
<?php if (!empty($this->backupTrend)) : ?>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-chart" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND'); ?>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php
|
||||||
|
$maxDaySize = max(array_column($this->backupTrend, 'day_size')) ?: 1;
|
||||||
|
?>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:2px; height:120px; overflow-x:auto;">
|
||||||
|
<?php foreach ($this->backupTrend as $day) : ?>
|
||||||
|
<?php
|
||||||
|
$barHeight = max(4, round(($day->day_size / $maxDaySize) * 100));
|
||||||
|
$barColor = $day->fail_count > 0 ? '#dc3545' : '#198754';
|
||||||
|
$tooltip = date('M j', strtotime($day->backup_date))
|
||||||
|
. ' — ' . $day->day_count . ' backup(s), '
|
||||||
|
. number_format($day->day_size / 1048576, 1) . ' MB'
|
||||||
|
. ($day->fail_count > 0 ? ', ' . $day->fail_count . ' failed' : '');
|
||||||
|
?>
|
||||||
|
<div style="flex:1; min-width:8px; max-width:24px; height:<?php echo $barHeight; ?>%; background:<?php echo $barColor; ?>; border-radius:2px 2px 0 0; cursor:default;"
|
||||||
|
title="<?php echo htmlspecialchars($tooltip); ?>"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted"><?php echo date('M j', strtotime('-30 days')); ?></small>
|
||||||
|
<small class="text-muted"><?php echo date('M j'); ?></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Row 2: Quick Actions -->
|
<!-- Row 2: Quick Actions -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||||
|
<?php if (in_array('articles', $types)) : ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||||
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
|
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||||
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||||
|
<span class="icon-search"></span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||||
@@ -227,6 +235,55 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Browse Snapshot Articles 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:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
||||||
|
<div 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 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'); ?>
|
||||||
|
</div>
|
||||||
|
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||||
|
<div id="mb-browse-content" style="display:none;">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-check form-check-inline">
|
||||||
|
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||||
|
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||||
|
</label>
|
||||||
|
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
||||||
|
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
|
<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'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// Create Snapshot — intercept toolbar button
|
// Create Snapshot — intercept toolbar button
|
||||||
@@ -312,13 +369,124 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browse Snapshot — click handler
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.mb-snapshot-browse');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var desc = btn.getAttribute('data-desc');
|
||||||
|
|
||||||
|
document.getElementById('mb-browse-id').value = id;
|
||||||
|
document.getElementById('mb-browse-title').textContent = 'Browse: ' + desc;
|
||||||
|
|
||||||
|
// Reset modal state
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'block';
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-content').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||||
|
document.getElementById('mb-browse-select-all').checked = false;
|
||||||
|
|
||||||
|
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||||
|
|
||||||
|
// Fetch articles via AJAX
|
||||||
|
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||||
|
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||||
|
|
||||||
|
fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById('mb-browse-error').textContent = data.message;
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tbody = document.getElementById('mb-browse-tbody');
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
|
||||||
|
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||||
|
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||||
|
|
||||||
|
data.articles.forEach(function(article) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdCheck = document.createElement('td');
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'form-check-input mb-browse-article-cb';
|
||||||
|
cb.name = 'article_ids[]';
|
||||||
|
cb.value = article.id;
|
||||||
|
tdCheck.appendChild(cb);
|
||||||
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = article.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = article.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(article.state)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(article.state)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
var tdDate = document.createElement('td');
|
||||||
|
tdDate.textContent = article.created ? article.created.substring(0, 10) : '';
|
||||||
|
tr.appendChild(tdDate);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
|
||||||
|
document.getElementById('mb-browse-content').style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browse — select all toggle
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.id === 'mb-browse-select-all') {
|
||||||
|
var checked = e.target.checked;
|
||||||
|
var checkboxes = document.querySelectorAll('.mb-browse-article-cb');
|
||||||
|
checkboxes.forEach(function(cb) { cb.checked = checked; });
|
||||||
|
updateBrowseRestoreBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('mb-browse-article-cb')) {
|
||||||
|
updateBrowseRestoreBtn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBrowseRestoreBtn() {
|
||||||
|
var checked = document.querySelectorAll('.mb-browse-article-cb:checked').length;
|
||||||
|
var btn = document.getElementById('mb-browse-restore-btn');
|
||||||
|
btn.disabled = checked === 0;
|
||||||
|
btn.textContent = checked > 0
|
||||||
|
? <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?> + ' (' + checked + ')'
|
||||||
|
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
||||||
|
}
|
||||||
|
|
||||||
// Close modals
|
// Close modals
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.classList.contains('mb-modal-close') ||
|
if (e.target.classList.contains('mb-modal-close') ||
|
||||||
e.target.id === 'mb-snapshot-create-modal' ||
|
e.target.id === 'mb-snapshot-create-modal' ||
|
||||||
e.target.id === 'mb-snapshot-restore-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-create-modal').style.display = 'none';
|
||||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
||||||
|
document.getElementById('mb-snapshot-browse-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="actionlog" method="upgrade">
|
<extension type="plugin" group="actionlog" method="upgrade">
|
||||||
<name>Action Log - MokoSuiteBackup</name>
|
<name>Action Log - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="console" method="upgrade">
|
<extension type="plugin" group="console" method="upgrade">
|
||||||
<name>Console - MokoSuiteBackup</name>
|
<name>Console - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
|||||||
use Joomla\Console\Command\AbstractCommand;
|
use Joomla\Console\Command\AbstractCommand;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ class RestoreCommand extends AbstractCommand
|
|||||||
{
|
{
|
||||||
$this->setDescription('Restore a backup by record ID');
|
$this->setDescription('Restore a backup by record ID');
|
||||||
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
|
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
|
||||||
|
$this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)');
|
||||||
|
$this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)');
|
||||||
|
$this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php');
|
||||||
|
$this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function doExecute(InputInterface $input, OutputInterface $output): int
|
protected function doExecute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -85,8 +90,22 @@ class RestoreCommand extends AbstractCommand
|
|||||||
require_once $engineFile;
|
require_once $engineFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$filesOnly = $input->getOption('files-only');
|
||||||
|
$dbOnly = $input->getOption('db-only');
|
||||||
|
$preserveConfig = !$input->getOption('no-preserve-config');
|
||||||
|
$password = $input->getOption('password') ?: '';
|
||||||
|
|
||||||
|
$restoreFiles = !$dbOnly;
|
||||||
|
$restoreDb = !$filesOnly;
|
||||||
|
|
||||||
|
if ($filesOnly) {
|
||||||
|
$io->note('Restoring files only (database will not be touched)');
|
||||||
|
} elseif ($dbOnly) {
|
||||||
|
$io->note('Restoring database only (files will not be touched)');
|
||||||
|
}
|
||||||
|
|
||||||
$engine = new RestoreEngine();
|
$engine = new RestoreEngine();
|
||||||
$result = $engine->restore($recordId);
|
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$io->success($result['message']);
|
$io->success($result['message']);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="content" method="upgrade">
|
<extension type="plugin" group="content" method="upgrade">
|
||||||
<name>Content - MokoSuiteBackup</name>
|
<name>Content - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<extension type="plugin" group="quickicon" method="upgrade">
|
<extension type="plugin" group="quickicon" method="upgrade">
|
||||||
<name>Quick Icon - MokoSuiteBackup</name>
|
<name>Quick Icon - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="system" method="upgrade">
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
<name>System - MokoSuiteBackup</name>
|
<name>System - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="task" method="upgrade">
|
<extension type="plugin" group="task" method="upgrade">
|
||||||
<name>Task - MokoSuiteBackup</name>
|
<name>Task - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="webservices" method="upgrade">
|
<extension type="plugin" group="webservices" method="upgrade">
|
||||||
<name>Web Services - MokoSuiteBackup</name>
|
<name>Web Services - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteBackup</name>
|
<name>Package - MokoSuiteBackup</name>
|
||||||
<packagename>mokosuitebackup</packagename>
|
<packagename>mokosuitebackup</packagename>
|
||||||
<version>01.32.00</version>
|
<version>01.35.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
+24
-24
@@ -58,7 +58,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required PHP extensions (warn but don't block install)
|
/* Check required PHP extensions (warn but don't block install) */
|
||||||
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
|
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
|
||||||
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
|
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save download key before Joomla re-registers the update site
|
/* Save download key before Joomla re-registers the update site */
|
||||||
if ($type === 'update') {
|
if ($type === 'update') {
|
||||||
$this->preflight_saveKey();
|
$this->preflight_saveKey();
|
||||||
}
|
}
|
||||||
@@ -138,43 +138,43 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore download key if it was saved before update
|
/* Restore download key if it was saved before update */
|
||||||
if ($this->savedDownloadKey !== null) {
|
if ($this->savedDownloadKey !== null) {
|
||||||
$this->restoreDownloadKey();
|
$this->restoreDownloadKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'install') {
|
if ($type === 'install') {
|
||||||
// Enable all bundled plugins on fresh install
|
/* Enable all bundled plugins on fresh install */
|
||||||
$this->enableBundledPlugins();
|
$this->enableBundledPlugins();
|
||||||
|
|
||||||
// Create default backup directory in site root
|
/* Create default backup directory in site root */
|
||||||
$this->createBackupDirectory();
|
$this->createBackupDirectory();
|
||||||
|
|
||||||
// Generate a random webcron secret word
|
/* Generate a random webcron secret word */
|
||||||
$this->generateWebcronSecret();
|
$this->generateWebcronSecret();
|
||||||
|
|
||||||
// Create default scheduled task for backup automation
|
/* Create default scheduled task for backup automation */
|
||||||
$this->createDefaultScheduledTask();
|
$this->createDefaultScheduledTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure submenu items exist and are up to date
|
/* Ensure submenu items exist and are up to date */
|
||||||
// (Joomla may not add new submenu entries or update params on upgrades)
|
/* (Joomla may not add new submenu entries or update params on upgrades) */
|
||||||
$this->ensureSubmenuItems();
|
$this->ensureSubmenuItems();
|
||||||
|
|
||||||
// Fix package client_id — packages must be client_id=0 (site) for
|
/* Fix package client_id — packages must be client_id=0 (site) for */
|
||||||
// Joomla's updater to match the <client>site</client> in updates.xml
|
/* Joomla's updater to match the <client>site</client> in updates.xml */
|
||||||
$this->fixPackageClientId();
|
$this->fixPackageClientId();
|
||||||
|
|
||||||
// Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades)
|
/* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */
|
||||||
$this->syncMenuIcons();
|
$this->syncMenuIcons();
|
||||||
|
|
||||||
// Warn if no license key configured
|
/* Warn if no license key configured */
|
||||||
$this->warnMissingLicenseKey();
|
$this->warnMissingLicenseKey();
|
||||||
|
|
||||||
// Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder
|
/* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */
|
||||||
$this->migrateDefaultBackupDir();
|
$this->migrateDefaultBackupDir();
|
||||||
|
|
||||||
// Remind user to review backup profile settings
|
/* Remind user to review backup profile settings */
|
||||||
if ($type === 'install') {
|
if ($type === 'install') {
|
||||||
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
|
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Load current component params
|
/* Load current component params */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('params'))
|
->select($db->quoteName('params'))
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
@@ -208,7 +208,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
|
|
||||||
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
||||||
|
|
||||||
// Only generate if not already set
|
/* Only generate if not already set */
|
||||||
if (!empty($params['webcron_secret'])) {
|
if (!empty($params['webcron_secret'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protect directory from direct web access
|
/* Protect directory from direct web access */
|
||||||
$htaccess = $backupDir . '/.htaccess';
|
$htaccess = $backupDir . '/.htaccess';
|
||||||
|
|
||||||
if (!file_exists($htaccess)) {
|
if (!file_exists($htaccess)) {
|
||||||
@@ -361,7 +361,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Check if a MokoSuiteBackup task already exists
|
/* Check if a MokoSuiteBackup task already exists */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select('COUNT(*)')
|
||||||
->from($db->quoteName('#__scheduler_tasks'))
|
->from($db->quoteName('#__scheduler_tasks'))
|
||||||
@@ -460,7 +460,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Find the parent menu item for our component
|
/* Find the parent menu item for our component */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select([$db->quoteName('id'), $db->quoteName('menutype')])
|
->select([$db->quoteName('id'), $db->quoteName('menutype')])
|
||||||
->from($db->quoteName('#__menu'))
|
->from($db->quoteName('#__menu'))
|
||||||
@@ -476,7 +476,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the component extension_id
|
/* Get the component extension_id */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('extension_id'))
|
->select($db->quoteName('extension_id'))
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
@@ -492,7 +492,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($submenus as $submenu) {
|
foreach ($submenus as $submenu) {
|
||||||
// Check if this submenu item already exists
|
/* Check if this submenu item already exists */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||||
->from($db->quoteName('#__menu'))
|
->from($db->quoteName('#__menu'))
|
||||||
@@ -503,7 +503,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
$existing = $db->loadObject();
|
$existing = $db->loadObject();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Merge menu_icon into existing params to preserve other settings
|
/* Merge menu_icon into existing params to preserve other settings */
|
||||||
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
|
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
|
||||||
$existingParams['menu_icon'] = $submenu['menu_icon'];
|
$existingParams['menu_icon'] = $submenu['menu_icon'];
|
||||||
$mergedParams = json_encode($existingParams);
|
$mergedParams = json_encode($existingParams);
|
||||||
@@ -517,7 +517,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Joomla's MenuTable to create the item properly
|
/* Use Joomla's MenuTable to create the item properly */
|
||||||
$table = Factory::getApplication()
|
$table = Factory::getApplication()
|
||||||
->bootComponent('com_menus')
|
->bootComponent('com_menus')
|
||||||
->getMVCFactory()
|
->getMVCFactory()
|
||||||
|
|||||||
Reference in New Issue
Block a user