Compare commits
13 Commits
version/01.39.01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bafaa519a | |||
| 3c32bd93e9 | |||
| ef17873448 | |||
| dae30161ae | |||
| 8e70bfb723 | |||
| dcd772018e | |||
| 26d765b74e | |||
| 78b68d2647 | |||
| 50a879155d | |||
| b4fb674566 | |||
| 1b93d2ac21 | |||
| 8e5913d706 | |||
| 1f7def05c1 |
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.00.00
|
||||
# VERSION: 01.41.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+534
-534
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+17
-9
@@ -1,7 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.41.00] --- 2026-06-23
|
||||
|
||||
## [01.41.00] --- 2026-06-23
|
||||
|
||||
### Added
|
||||
- Multi-remote storage: new `#__mokosuitebackup_remotes` table for multiple destinations per profile (#97)
|
||||
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit view
|
||||
- Engine integration: BackupEngine and SteppedBackupEngine upload to all enabled destinations
|
||||
- Migration SQL: auto-migrates existing SFTP/S3/GDrive/FTP configs to new table
|
||||
- Backward compatibility: falls back to legacy single-remote columns if remotes table is empty
|
||||
- Secrets masked in API responses, merged from DB on save to prevent leakage
|
||||
|
||||
## [01.40.00] --- 2026-06-23
|
||||
|
||||
|
||||
## [01.40.00] --- 2026-06-23
|
||||
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
## [01.39.01] --- 2026-06-23
|
||||
@@ -20,11 +36,3 @@
|
||||
### Fixed
|
||||
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
|
||||
- MokoRestore: temporary password is now randomly generated (not hardcoded "changeme")
|
||||
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
|
||||
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
|
||||
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
|
||||
*/
|
||||
public function displayList(): static
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->app->setHeader('status', 403);
|
||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||
$this->app->close();
|
||||
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
|
||||
*/
|
||||
public function download(): static
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->app->setHeader('status', 403);
|
||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||
$this->app->close();
|
||||
|
||||
@@ -39,6 +39,73 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="defaults" label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS">
|
||||
<field
|
||||
name="default_archive_format"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC"
|
||||
default="zip"
|
||||
>
|
||||
<option value="zip">ZIP</option>
|
||||
<option value="tar.gz">tar.gz</option>
|
||||
<option value="7z">7z</option>
|
||||
</field>
|
||||
<field
|
||||
name="default_mokorestore"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
>
|
||||
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="default_sanitize_passwords"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="default_sanitize_emails"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="default_sanitize_sessions"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="log_retention_days"
|
||||
type="number"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC"
|
||||
default="90"
|
||||
min="0"
|
||||
max="365"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
||||
<field
|
||||
name="webcron_secret"
|
||||
@@ -172,6 +239,32 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ntfy" label="COM_MOKOJOOMBACKUP_CONFIG_NTFY">
|
||||
<field
|
||||
name="ntfy_server"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
|
||||
default="https://ntfy.sh"
|
||||
filter="url"
|
||||
/>
|
||||
<field
|
||||
name="ntfy_topic"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="ntfy_token"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC"
|
||||
default=""
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
||||
description="JCONFIG_PERMISSIONS_DESC">
|
||||
<field
|
||||
|
||||
@@ -202,6 +202,13 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||
<field
|
||||
name="remote_legacy_note"
|
||||
type="note"
|
||||
label=""
|
||||
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
|
||||
class="alert alert-info small"
|
||||
/>
|
||||
<field
|
||||
name="remote_storage"
|
||||
type="list"
|
||||
|
||||
@@ -414,6 +414,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||
|
||||
; Component Options — Defaults
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS="Profile Defaults"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT="Default Archive Format"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC="Archive format used when creating new profiles. Can be overridden per profile."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE="Default MokoRestore Mode"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC="MokoRestore mode for new profiles. None, Wrapped (inside ZIP), or Standalone (separate file)."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW="Default: Sanitize Passwords"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC="Whether new profiles should sanitize user passwords by default."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL="Default: Sanitize Emails"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC="Whether new profiles should sanitize user emails by default."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS="Default: Clear Sessions"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC="Whether new profiles should clear session data by default."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION="Log Retention (days)"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC="Days to keep .log files alongside backup archives. Set to 0 for unlimited."
|
||||
|
||||
; Component Options — ntfy
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY="Push Notifications (ntfy)"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER="Global ntfy Server"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC="Default ntfy server URL. Per-profile settings override this."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC="Global ntfy Topic"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC="Default ntfy topic for backup notifications. Per-profile settings override this."
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN="Global ntfy Token"
|
||||
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC="Default access token for private ntfy topics. Per-profile settings override this."
|
||||
|
||||
; ACL — additional actions
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE="Purge Old Backups"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE_DESC="Allows users to bulk-delete backups older than a specific date."
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE="Compare Backups"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side."
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
|
||||
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting."
|
||||
|
||||
; Snapshot ACL
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
||||
@@ -463,6 +495,15 @@ COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date.
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||
|
||||
; Remote Destinations (multi-remote)
|
||||
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_EDIT="Edit Destination"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_ENABLED="Enabled"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED="No remote destinations configured. Use 'Add Destination' to send backups to SFTP, S3, or Google Drive."
|
||||
COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE="Legacy single-remote fields below are hidden when remote destinations are configured above. Existing legacy settings continue to work as a fallback."
|
||||
COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM="Are you sure you want to delete this remote destination?"
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -107,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_enabled` (`enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- MokoSuiteBackup 01.41.00 — Multi-remote storage destinations (#97)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Migrate existing SFTP remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - SFTP'),
|
||||
'sftp',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'host', `sftp_host`,
|
||||
'port', `sftp_port`,
|
||||
'username', `sftp_username`,
|
||||
'auth_type', `sftp_auth_type`,
|
||||
'password', `sftp_password`,
|
||||
'key_data', COALESCE(`sftp_key_data`, ''),
|
||||
'passphrase', `sftp_passphrase`,
|
||||
'path', `sftp_path`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'sftp' AND `sftp_host` != '';
|
||||
|
||||
-- Migrate existing S3 remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - S3'),
|
||||
's3',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'endpoint', `s3_endpoint`,
|
||||
'region', `s3_region`,
|
||||
'access_key', `s3_access_key`,
|
||||
'secret_key', `s3_secret_key`,
|
||||
'bucket', `s3_bucket`,
|
||||
'path', `s3_path`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 's3' AND `s3_bucket` != '';
|
||||
|
||||
-- Migrate existing Google Drive remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - Google Drive'),
|
||||
'google_drive',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'client_id', `gdrive_client_id`,
|
||||
'client_secret', `gdrive_client_secret`,
|
||||
'refresh_token', `gdrive_refresh_token`,
|
||||
'folder_id', `gdrive_folder_id`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'google_drive' AND `gdrive_client_id` != '';
|
||||
|
||||
-- Migrate existing FTP remote configs into new table
|
||||
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||
SELECT
|
||||
`id`,
|
||||
CONCAT(`title`, ' - FTP'),
|
||||
'ftp',
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'host', `ftp_host`,
|
||||
'port', `ftp_port`,
|
||||
'username', `ftp_username`,
|
||||
'password', `ftp_password`,
|
||||
'path', `ftp_path`,
|
||||
'passive', `ftp_passive`,
|
||||
'ssl', `ftp_ssl`
|
||||
),
|
||||
1,
|
||||
NOW()
|
||||
FROM `#__mokosuitebackup_profiles`
|
||||
WHERE `remote_storage` = 'ftp' AND `ftp_host` != '';
|
||||
@@ -348,7 +348,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -384,7 +384,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -416,7 +416,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.browse', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -725,7 +725,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -776,7 +776,7 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
@@ -879,6 +879,335 @@ class AjaxController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Remote Destinations CRUD
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List remote destinations for a profile.
|
||||
* POST: task=ajax.listRemotes&profile_id=1
|
||||
*/
|
||||
public function listRemotes(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if (!$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList();
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode JSON config and mask secrets
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$config = json_decode($row->config, true) ?: [];
|
||||
|
||||
// Mask sensitive fields so they never leave the server in list views
|
||||
$masked = $this->maskSecrets($config, $row->type);
|
||||
|
||||
$items[] = [
|
||||
'id' => (int) $row->id,
|
||||
'profile_id' => (int) $row->profile_id,
|
||||
'title' => $row->title,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'keep_local' => (int) $row->keep_local,
|
||||
'config' => $masked,
|
||||
'ordering' => (int) $row->ordering,
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJson(['error' => false, 'items' => $items]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (create or update) a remote destination.
|
||||
* POST: task=ajax.saveRemote (JSON body or form fields)
|
||||
*/
|
||||
public function saveRemote(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('remote_id', 0);
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
$title = trim($this->input->getString('remote_title', ''));
|
||||
$type = $this->input->getCmd('remote_type', 'sftp');
|
||||
$enabled = $this->input->getInt('remote_enabled', 1);
|
||||
$keepLocal = $this->input->getInt('remote_keep_local', 1);
|
||||
$configRaw = $this->input->getString('remote_config', '{}');
|
||||
|
||||
if (!$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($title)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Title is required']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$config = json_decode($configRaw, true);
|
||||
|
||||
if (!is_array($config)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid config JSON']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If editing, merge secrets that were masked with __KEEP_EXISTING__
|
||||
if ($id) {
|
||||
$config = $this->mergeExistingSecrets($id, $config, $type);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
try {
|
||||
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
|
||||
|
||||
if ($id) {
|
||||
$table->load($id);
|
||||
|
||||
// Verify ownership
|
||||
if ((int) $table->profile_id !== $profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$table->profile_id = $profileId;
|
||||
$table->title = $title;
|
||||
$table->type = $type;
|
||||
$table->enabled = $enabled ? 1 : 0;
|
||||
$table->keep_local = $keepLocal ? 1 : 0;
|
||||
$table->config = json_encode($config);
|
||||
|
||||
if (!$table->check() || !$table->store()) {
|
||||
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']);
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a remote destination.
|
||||
* POST: task=ajax.deleteRemote&remote_id=1&profile_id=1
|
||||
*/
|
||||
public function deleteRemote(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('remote_id', 0);
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if (!$id || !$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
$this->sendJson(['error' => false, 'message' => 'Deleted']);
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle enabled/disabled for a remote destination.
|
||||
* POST: task=ajax.toggleRemote&remote_id=1&profile_id=1
|
||||
*/
|
||||
public function toggleRemote(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('remote_id', 0);
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if (!$id || !$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load current state
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('enabled'))
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$current = $db->loadResult();
|
||||
|
||||
if ($current === null) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Remote not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newState = $current ? 0 : 1;
|
||||
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->set($db->quoteName('enabled') . ' = ' . $newState)
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
$this->sendJson(['error' => false, 'enabled' => $newState]);
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive values in a remote config array for display.
|
||||
*/
|
||||
private function maskSecrets(array $config, string $type): array
|
||||
{
|
||||
$secrets = [
|
||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||
's3' => ['secret_key'],
|
||||
'google_drive' => ['client_secret', 'refresh_token'],
|
||||
];
|
||||
|
||||
$fields = $secrets[$type] ?? [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($config[$field])) {
|
||||
$config[$field] = '********';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* When updating a remote, merge back secrets that were masked in the form.
|
||||
*/
|
||||
private function mergeExistingSecrets(int $id, array $config, string $type): array
|
||||
{
|
||||
$secrets = [
|
||||
'sftp' => ['password', 'passphrase', 'key_data'],
|
||||
's3' => ['secret_key'],
|
||||
'google_drive' => ['client_secret', 'refresh_token'],
|
||||
];
|
||||
|
||||
$fields = $secrets[$type] ?? [];
|
||||
$needsMerge = false;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
||||
$needsMerge = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$needsMerge) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
// Load existing config from DB
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('config'))
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$existing = json_decode($db->loadResult() ?: '{}', true) ?: [];
|
||||
} catch (\Exception $e) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
|
||||
$config[$field] = $existing[$field] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse directories on a remote SFTP server for the path picker.
|
||||
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
||||
|
||||
@@ -176,7 +176,7 @@ class BackupsController extends AdminController
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ class SnapshotsController extends AdminController
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
|
||||
@@ -288,47 +288,81 @@ class BackupEngine
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
// Step 3: Remote upload (if configured)
|
||||
// Wrapped in its own try-catch so a remote failure does not mark
|
||||
// the entire backup as failed — the local archive is preserved.
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
/* Step 3: Remote upload — iterate all enabled destinations */
|
||||
$remotes = $this->loadRemoteDestinations($db, $profileId);
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
try {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
if (!empty($remotes)) {
|
||||
foreach ($remotes as $remote) {
|
||||
try {
|
||||
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
|
||||
$params = json_decode($remote->params, true) ?: [];
|
||||
$uploader = $this->createUploaderFromParams($remote->type, $params);
|
||||
$result = $uploader->upload($archivePath, $archiveName);
|
||||
|
||||
if ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||
$this->log(' Upload complete: ' . $result['message']);
|
||||
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||
/* Upload standalone restore.php if in standalone mode */
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$uploader->upload($restoreScriptPath, 'restore.php');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* Delete local copy only when ALL remotes succeeded and profile says so */
|
||||
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
/* Backward-compat: fall back to legacy single-remote column */
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
try {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
|
||||
if ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +521,8 @@ class BackupEngine
|
||||
|
||||
/**
|
||||
* Create the appropriate remote uploader based on the storage type.
|
||||
* Legacy method — used by backward-compat fallback when remotes table
|
||||
* does not exist.
|
||||
*/
|
||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||
{
|
||||
@@ -499,6 +535,59 @@ class BackupEngine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||
*
|
||||
* Builds a fake profile-like object from the params array so the existing
|
||||
* uploader constructors work without modification.
|
||||
*
|
||||
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||
* @param array $params Key-value params decoded from the remote's JSON
|
||||
*
|
||||
* @return RemoteUploaderInterface
|
||||
*/
|
||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||
{
|
||||
$fake = (object) $params;
|
||||
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($fake),
|
||||
'sftp' => new SftpUploader($fake),
|
||||
'google_drive' => new GoogleDriveUploader($fake),
|
||||
's3' => new S3Uploader($fake),
|
||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load enabled remote destinations for a profile from the remotes table.
|
||||
*
|
||||
* Returns an empty array when the table does not exist (pre-migration)
|
||||
* so the caller can fall back to the legacy single-remote column.
|
||||
*
|
||||
* @param object $db Database driver
|
||||
* @param int $profileId Profile ID
|
||||
*
|
||||
* @return object[] Array of remote destination rows
|
||||
*/
|
||||
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||
{
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file manifest from the most recent full backup for this profile.
|
||||
* Used by differential backups to determine which files changed.
|
||||
|
||||
@@ -73,6 +73,10 @@ class SteppedBackupEngine
|
||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||
|
||||
// Load multi-remote destinations from the remotes table
|
||||
$session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId);
|
||||
$session->remoteIndex = 0;
|
||||
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
||||
@@ -147,13 +151,22 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
$totalSteps += 1; // finalize step
|
||||
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
|
||||
|
||||
// Determine upload step count: one step per remote destination,
|
||||
// or one step for legacy single-remote, or zero if no remotes.
|
||||
$remoteCount = count($session->remoteDestinations);
|
||||
|
||||
if ($remoteCount > 0) {
|
||||
$totalSteps += $remoteCount;
|
||||
} elseif ($session->remoteStorage !== 'none') {
|
||||
$totalSteps += 1;
|
||||
}
|
||||
|
||||
$session->totalSteps = $totalSteps;
|
||||
$session->currentStep = 1;
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')');
|
||||
// Log any preflight warnings into the session
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||
@@ -391,7 +404,17 @@ class SteppedBackupEngine
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
|
||||
|
||||
// Determine next phase: multi-remote, legacy single-remote, or complete
|
||||
$hasMultiRemote = !empty($session->remoteDestinations);
|
||||
$hasLegacyRemote = $session->remoteStorage !== 'none';
|
||||
|
||||
if ($hasMultiRemote || $hasLegacyRemote) {
|
||||
$session->phase = 'upload';
|
||||
} else {
|
||||
$session->phase = 'complete';
|
||||
}
|
||||
|
||||
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
||||
$session->log('Archive finalized: ' . $sizeHuman);
|
||||
|
||||
@@ -402,6 +425,10 @@ class SteppedBackupEngine
|
||||
|
||||
/**
|
||||
* Upload phase: send archive to remote storage.
|
||||
*
|
||||
* When multi-remote destinations are configured, each call uploads to
|
||||
* one destination (one step per remote). When only the legacy
|
||||
* single-remote column is set, uploads in a single step.
|
||||
*/
|
||||
private function stepUpload(SteppedSession $session): void
|
||||
{
|
||||
@@ -409,62 +436,126 @@ class SteppedBackupEngine
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
// Wrapped in its own try-catch so a remote failure does not mark
|
||||
// the entire backup as failed — the local archive is preserved.
|
||||
try {
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
if (!empty($session->remoteDestinations)) {
|
||||
// ── Multi-remote path ──────────────────────────────────
|
||||
$index = $session->remoteIndex;
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
if ($index >= count($session->remoteDestinations)) {
|
||||
// All remotes processed — move to complete
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'All remote uploads finished';
|
||||
$this->completeRecord($session);
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
$remote = (object) $session->remoteDestinations[$index];
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
try {
|
||||
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||
$type = $remote->type ?? 'unknown';
|
||||
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||
|
||||
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||
$uploader = $this->createUploaderFromParams($type, $params);
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log(' Upload complete: ' . $result['message']);
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||
}
|
||||
} else {
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$session->remoteIndex++;
|
||||
$session->currentStep++;
|
||||
|
||||
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||
|
||||
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||
// All remotes done — delete local if configured and no failures
|
||||
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
} else {
|
||||
// ── Legacy single-remote fallback ──────────────────────
|
||||
try {
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
}
|
||||
} else {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$uploadFailed = true;
|
||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||
$session->log('Local backup is preserved.');
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = $uploadFailed
|
||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||
: 'Backup complete';
|
||||
$this->completeRecord($session, $uploadFailed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -729,4 +820,58 @@ class SteppedBackupEngine
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load enabled remote destinations for a profile from the remotes table.
|
||||
*
|
||||
* Returns an empty array when the table does not exist (pre-migration)
|
||||
* so the caller can fall back to the legacy single-remote column.
|
||||
*
|
||||
* @param object $db Database driver
|
||||
* @param int $profileId Profile ID
|
||||
*
|
||||
* @return array Array of remote destination rows (as associative arrays for JSON serialization)
|
||||
*/
|
||||
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||
{
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
// Use loadAssocList so the data survives JSON serialization in SteppedSession
|
||||
return $db->loadAssocList() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||
*
|
||||
* Builds a fake profile-like object from the params array so the existing
|
||||
* uploader constructors work without modification.
|
||||
*
|
||||
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||
* @param array $params Key-value params decoded from the remote's JSON
|
||||
*
|
||||
* @return RemoteUploaderInterface
|
||||
*/
|
||||
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||
{
|
||||
$fake = (object) $params;
|
||||
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($fake),
|
||||
'sftp' => new SftpUploader($fake),
|
||||
'google_drive' => new GoogleDriveUploader($fake),
|
||||
's3' => new S3Uploader($fake),
|
||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ class SteppedSession
|
||||
public bool $remoteKeepLocal = true;
|
||||
public string $encryptionPassword = '';
|
||||
|
||||
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
|
||||
public array $remoteDestinations = [];
|
||||
public int $remoteIndex = 0;
|
||||
|
||||
// Progress
|
||||
public int $totalSteps = 0;
|
||||
public int $currentStep = 0;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\AdminModel;
|
||||
|
||||
class RemoteModel extends AdminModel
|
||||
{
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokosuitebackup.remote',
|
||||
'remote',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
return $form ?: false;
|
||||
}
|
||||
|
||||
protected function loadFormData(): object
|
||||
{
|
||||
$data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.remote.data', []);
|
||||
|
||||
if (empty($data)) {
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return is_array($data) ? (object) $data : $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Remote', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled remotes for a given profile.
|
||||
*
|
||||
* @param int $profileId The profile ID
|
||||
*
|
||||
* @return array Array of remote objects
|
||||
*/
|
||||
public function getEnabledByProfile(int $profileId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
class RemotesModel extends ListModel
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields'])) {
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'profile_id', 'a.profile_id',
|
||||
'title', 'a.title',
|
||||
'type', 'a.type',
|
||||
'enabled', 'a.enabled',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_remotes', 'a'));
|
||||
|
||||
// Join profile title
|
||||
$query->select($db->quoteName('p.title', 'profile_title'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = a.profile_id');
|
||||
|
||||
// Filter by profile
|
||||
$profileId = $this->getState('filter.profile_id');
|
||||
|
||||
if (is_numeric($profileId)) {
|
||||
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
$type = $this->getState('filter.type');
|
||||
|
||||
if (!empty($type)) {
|
||||
$query->where($db->quoteName('a.type') . ' = ' . $db->quote($type));
|
||||
}
|
||||
|
||||
// Filter by enabled
|
||||
$enabled = $this->getState('filter.enabled');
|
||||
|
||||
if (is_numeric($enabled)) {
|
||||
$query->where($db->quoteName('a.enabled') . ' = ' . (int) $enabled);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search)) {
|
||||
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
|
||||
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
||||
}
|
||||
|
||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
||||
{
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
class RemoteTable extends Table
|
||||
{
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokosuitebackup_remotes', 'id', $db);
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->profile_id)) {
|
||||
$this->setError('Profile ID is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$validTypes = ['sftp', 's3', 'google_drive', 'ftp'];
|
||||
|
||||
if (empty($this->type) || !\in_array($this->type, $validTypes, true)) {
|
||||
$this->setError('Invalid remote type. Must be one of: ' . implode(', ', $validTypes));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->title)) {
|
||||
$this->title = ucfirst(str_replace('_', ' ', $this->type)) . ' Remote';
|
||||
}
|
||||
|
||||
// Ensure params is valid JSON
|
||||
if (!empty($this->params) && \is_string($this->params)) {
|
||||
$decoded = json_decode($this->params);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->setError('Remote params must be valid JSON.');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
|
||||
$this->created = $now;
|
||||
}
|
||||
|
||||
$this->modified = $now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the params as a decoded object.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function getParams(): object
|
||||
{
|
||||
if (empty($this->params)) {
|
||||
return (object) [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($this->params);
|
||||
|
||||
return \is_object($decoded) ? $decoded : (object) [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set params from an array or object, encoding to JSON.
|
||||
*
|
||||
* @param array|object $params The parameters to encode
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setParams(array|object $params): void
|
||||
{
|
||||
$this->params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@@ -120,14 +120,19 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
||||
}
|
||||
|
||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
||||
|
||||
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
||||
}
|
||||
|
||||
if ($user->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||
}
|
||||
|
||||
if ($user->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.formvalidator');
|
||||
HTMLHelper::_('behavior.keepalive');
|
||||
|
||||
$profileId = (int) $this->item->id;
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . (int) $this->item->id); ?>"
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . $profileId); ?>"
|
||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
||||
|
||||
<div class="main-card">
|
||||
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<?php echo $this->form->renderFieldset('remote'); ?>
|
||||
<?php echo $this->form->renderFieldset('ftp'); ?>
|
||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||
<div class="col-lg-12">
|
||||
<?php // ---- Remote Destinations (multi-remote) ---- ?>
|
||||
<?php if ($profileId): ?>
|
||||
<div id="mokoRemoteDestinations" class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS'); ?></h3>
|
||||
<button type="button" class="btn btn-success btn-sm" id="btnAddRemote">
|
||||
<span class="icon-plus" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="table" id="remoteDestTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th style="width:120px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||
<th style="width:100px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS'); ?></th>
|
||||
<th style="width:160px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="remoteDestBody">
|
||||
<tr id="remoteDestLoading">
|
||||
<td colspan="4" class="text-center text-muted">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted small" id="remoteDestEmpty" style="display:none;">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
|
||||
</p>
|
||||
</div>
|
||||
<hr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ---- Legacy single-remote fields ---- ?>
|
||||
<div id="legacyRemoteFields">
|
||||
<div class="alert alert-info small" id="legacyRemoteNote" style="display:none;">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE'); ?>
|
||||
</div>
|
||||
<?php echo $this->form->renderFieldset('remote'); ?>
|
||||
<?php echo $this->form->renderFieldset('ftp'); ?>
|
||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
|
||||
<input type="hidden" name="task" value="">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
|
||||
<?php // ---- Remote Destination Add/Edit Modal ---- ?>
|
||||
<?php if ($profileId): ?>
|
||||
<div class="modal fade" id="remoteModal" tabindex="-1" aria-labelledby="remoteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="remoteModalLabel"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php echo Text::_('JCLOSE'); ?>"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="remoteEditId" value="0">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="remoteTitle" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteTitle" maxlength="255" required>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="remoteType" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></label>
|
||||
<select class="form-select" id="remoteType">
|
||||
<option value="sftp"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP'); ?></option>
|
||||
<option value="s3"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3'); ?></option>
|
||||
<option value="google_drive"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE'); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ENABLED'); ?></label>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="remoteEnabled" checked>
|
||||
<label class="form-check-label" for="remoteEnabled"><?php echo Text::_('JYES'); ?></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL'); ?></label>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="remoteKeepLocal" checked>
|
||||
<label class="form-check-label" for="remoteKeepLocal"><?php echo Text::_('JYES'); ?></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<?php // SFTP fields ?>
|
||||
<div id="remoteFields_sftp" class="remote-type-fields">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<label for="remoteCfg_sftp_host" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_sftp_host" maxlength="255">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="remoteCfg_sftp_port" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT'); ?></label>
|
||||
<input type="number" class="form-control" id="remoteCfg_sftp_port" value="22" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_sftp_username" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_sftp_username" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_sftp_auth_type" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE'); ?></label>
|
||||
<select class="form-select" id="remoteCfg_sftp_auth_type">
|
||||
<option value="key"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY'); ?></option>
|
||||
<option value="password"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD'); ?></option>
|
||||
<option value="key_passphrase"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE'); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3" id="remoteSftpPasswordWrap">
|
||||
<label for="remoteCfg_sftp_password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD'); ?></label>
|
||||
<input type="password" class="form-control" id="remoteCfg_sftp_password" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3" id="remoteSftpKeyWrap">
|
||||
<label for="remoteCfg_sftp_key_data" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY'); ?></label>
|
||||
<textarea class="form-control" id="remoteCfg_sftp_key_data" rows="4" placeholder="Paste SSH private key or leave as-is to keep existing"></textarea>
|
||||
</div>
|
||||
<div class="mb-3" id="remoteSftpPassphraseWrap">
|
||||
<label for="remoteCfg_sftp_passphrase" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE'); ?></label>
|
||||
<input type="password" class="form-control" id="remoteCfg_sftp_passphrase" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_sftp_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_sftp_path" value="/backups" maxlength="512">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // S3 fields ?>
|
||||
<div id="remoteFields_s3" class="remote-type-fields" style="display:none;">
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_endpoint" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_s3_endpoint" maxlength="512" placeholder="https://s3.amazonaws.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_region" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_REGION'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_s3_region" value="us-east-1" maxlength="50">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_access_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_s3_access_key" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_secret_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY'); ?></label>
|
||||
<input type="password" class="form-control" id="remoteCfg_s3_secret_key" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_bucket" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_s3_bucket" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_s3_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_PATH'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_s3_path" value="/backups" maxlength="512">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // Google Drive fields ?>
|
||||
<div id="remoteFields_google_drive" class="remote-type-fields" style="display:none;">
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_gdrive_client_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_gdrive_client_id" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_gdrive_client_secret" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET'); ?></label>
|
||||
<input type="password" class="form-control" id="remoteCfg_gdrive_client_secret" maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_gdrive_refresh_token" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_gdrive_refresh_token" maxlength="512">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="remoteCfg_gdrive_folder_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID'); ?></label>
|
||||
<input type="text" class="form-control" id="remoteCfg_gdrive_folder_id" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="button" class="btn btn-primary" id="btnSaveRemote">
|
||||
<span class="icon-save" aria-hidden="true"></span>
|
||||
<?php echo Text::_('JAPPLY'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
'use strict';
|
||||
|
||||
const profileId = <?php echo $profileId; ?>;
|
||||
const token = '<?php echo $token; ?>';
|
||||
|
||||
if (!profileId) return;
|
||||
|
||||
const baseUrl = 'index.php?option=com_mokosuitebackup&task=ajax.';
|
||||
const tbody = document.getElementById('remoteDestBody');
|
||||
const emptyMsg = document.getElementById('remoteDestEmpty');
|
||||
const loadingTr = document.getElementById('remoteDestLoading');
|
||||
const legacy = document.getElementById('legacyRemoteFields');
|
||||
const legacyNote = document.getElementById('legacyRemoteNote');
|
||||
const modal = new bootstrap.Modal(document.getElementById('remoteModal'));
|
||||
|
||||
// Type badge colours
|
||||
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
|
||||
const typeLabel = {
|
||||
sftp: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP', true); ?>',
|
||||
s3: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3', true); ?>',
|
||||
google_drive: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE', true); ?>'
|
||||
};
|
||||
|
||||
// Config field mappings per type
|
||||
const configFields = {
|
||||
sftp: ['host','port','username','auth_type','password','key_data','passphrase','path'],
|
||||
s3: ['endpoint','region','access_key','secret_key','bucket','path'],
|
||||
google_drive: ['client_id','client_secret','refresh_token','folder_id']
|
||||
};
|
||||
|
||||
// Prefix mapping for config field IDs
|
||||
const fieldPrefix = {sftp: 'sftp_', s3: 's3_', google_drive: 'gdrive_'};
|
||||
|
||||
let remotesData = [];
|
||||
|
||||
// ---- Load remotes ----
|
||||
function loadRemotes() {
|
||||
loadingTr.style.display = '';
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
fetch(baseUrl + 'listRemotes&profile_id=' + profileId + '&' + token + '=1', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
loadingTr.style.display = 'none';
|
||||
|
||||
if (data.error) {
|
||||
showTableMessage(data.message, 'text-danger');
|
||||
return;
|
||||
}
|
||||
|
||||
remotesData = data.items || [];
|
||||
renderTable();
|
||||
})
|
||||
.catch(() => {
|
||||
loadingTr.style.display = 'none';
|
||||
showTableMessage('Failed to load remotes', 'text-danger');
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
if (!remotesData.length) {
|
||||
emptyMsg.style.display = '';
|
||||
legacy.style.display = '';
|
||||
legacyNote.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMsg.style.display = 'none';
|
||||
legacy.style.display = 'none';
|
||||
legacyNote.style.display = 'block';
|
||||
|
||||
remotesData.forEach(function(item) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// Title cell
|
||||
const tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = item.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
// Type badge cell
|
||||
const tdType = document.createElement('td');
|
||||
const badgeSpan = document.createElement('span');
|
||||
badgeSpan.className = 'badge ' + (typeBadge[item.type] || 'bg-secondary');
|
||||
badgeSpan.textContent = typeLabel[item.type] || item.type;
|
||||
tdType.appendChild(badgeSpan);
|
||||
tr.appendChild(tdType);
|
||||
|
||||
// Enabled toggle cell
|
||||
const tdEnabled = document.createElement('td');
|
||||
const toggleSpan = document.createElement('span');
|
||||
toggleSpan.className = 'badge ' + (item.enabled ? 'bg-success' : 'bg-secondary');
|
||||
toggleSpan.style.cursor = 'pointer';
|
||||
toggleSpan.setAttribute('data-toggle-id', item.id);
|
||||
toggleSpan.textContent = item.enabled ? 'Enabled' : 'Disabled';
|
||||
tdEnabled.appendChild(toggleSpan);
|
||||
tr.appendChild(tdEnabled);
|
||||
|
||||
// Actions cell
|
||||
const tdActions = document.createElement('td');
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'btn btn-sm btn-outline-primary me-1';
|
||||
editBtn.setAttribute('data-edit-id', item.id);
|
||||
editBtn.title = 'Edit';
|
||||
const editIcon = document.createElement('span');
|
||||
editIcon.className = 'icon-pencil';
|
||||
editIcon.setAttribute('aria-hidden', 'true');
|
||||
editBtn.appendChild(editIcon);
|
||||
tdActions.appendChild(editBtn);
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-sm btn-outline-danger';
|
||||
delBtn.setAttribute('data-delete-id', item.id);
|
||||
delBtn.title = 'Delete';
|
||||
const delIcon = document.createElement('span');
|
||||
delIcon.className = 'icon-trash';
|
||||
delIcon.setAttribute('aria-hidden', 'true');
|
||||
delBtn.appendChild(delIcon);
|
||||
tdActions.appendChild(delBtn);
|
||||
|
||||
tr.appendChild(tdActions);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function showTableMessage(message, cssClass) {
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.setAttribute('colspan', '4');
|
||||
td.className = cssClass || '';
|
||||
td.textContent = message;
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// ---- Toggle enabled ----
|
||||
tbody.addEventListener('click', function(e) {
|
||||
const toggle = e.target.closest('[data-toggle-id]');
|
||||
if (toggle) {
|
||||
const id = toggle.getAttribute('data-toggle-id');
|
||||
const body = new URLSearchParams();
|
||||
body.set(token, '1');
|
||||
body.set('remote_id', id);
|
||||
body.set('profile_id', profileId);
|
||||
|
||||
fetch(baseUrl + 'toggleRemote', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
body: body
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { if (!data.error) loadRemotes(); })
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const editBtn = e.target.closest('[data-edit-id]');
|
||||
if (editBtn) {
|
||||
openEdit(parseInt(editBtn.getAttribute('data-edit-id'), 10));
|
||||
return;
|
||||
}
|
||||
|
||||
const delBtn = e.target.closest('[data-delete-id]');
|
||||
if (delBtn) {
|
||||
if (!confirm('<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM', true); ?>')) return;
|
||||
const id = delBtn.getAttribute('data-delete-id');
|
||||
const body = new URLSearchParams();
|
||||
body.set(token, '1');
|
||||
body.set('remote_id', id);
|
||||
body.set('profile_id', profileId);
|
||||
|
||||
fetch(baseUrl + 'deleteRemote', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
body: body
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { if (!data.error) loadRemotes(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Add button ----
|
||||
document.getElementById('btnAddRemote').addEventListener('click', function() {
|
||||
openEdit(0);
|
||||
});
|
||||
|
||||
// ---- Open modal for add / edit ----
|
||||
function openEdit(id) {
|
||||
document.getElementById('remoteEditId').value = id;
|
||||
document.getElementById('remoteTitle').value = '';
|
||||
document.getElementById('remoteType').value = 'sftp';
|
||||
document.getElementById('remoteEnabled').checked = true;
|
||||
document.getElementById('remoteKeepLocal').checked = true;
|
||||
|
||||
// Clear all config fields
|
||||
document.querySelectorAll('.remote-type-fields input, .remote-type-fields textarea, .remote-type-fields select').forEach(function(el) {
|
||||
if (el.type === 'number') {
|
||||
el.value = el.defaultValue || '';
|
||||
} else if (el.tagName === 'SELECT') {
|
||||
el.selectedIndex = 0;
|
||||
} else {
|
||||
el.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Restore defaults
|
||||
const portField = document.getElementById('remoteCfg_sftp_port');
|
||||
if (portField) portField.value = '22';
|
||||
const s3Region = document.getElementById('remoteCfg_s3_region');
|
||||
if (s3Region) s3Region.value = 'us-east-1';
|
||||
const sftpPath = document.getElementById('remoteCfg_sftp_path');
|
||||
if (sftpPath) sftpPath.value = '/backups';
|
||||
const s3Path = document.getElementById('remoteCfg_s3_path');
|
||||
if (s3Path) s3Path.value = '/backups';
|
||||
|
||||
if (id) {
|
||||
const item = remotesData.find(r => r.id === id);
|
||||
if (item) {
|
||||
document.getElementById('remoteTitle').value = item.title;
|
||||
document.getElementById('remoteType').value = item.type;
|
||||
document.getElementById('remoteEnabled').checked = !!item.enabled;
|
||||
document.getElementById('remoteKeepLocal').checked = !!item.keep_local;
|
||||
|
||||
// Populate config fields
|
||||
const prefix = fieldPrefix[item.type] || '';
|
||||
const fields = configFields[item.type] || [];
|
||||
fields.forEach(function(f) {
|
||||
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||
if (el && item.config && item.config[f] !== undefined) {
|
||||
el.value = item.config[f];
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('remoteModalLabel').textContent =
|
||||
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_EDIT', true); ?>';
|
||||
} else {
|
||||
document.getElementById('remoteModalLabel').textContent =
|
||||
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD', true); ?>';
|
||||
}
|
||||
|
||||
updateTypeFields();
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// ---- Type selector toggles field visibility ----
|
||||
document.getElementById('remoteType').addEventListener('change', updateTypeFields);
|
||||
|
||||
function updateTypeFields() {
|
||||
const type = document.getElementById('remoteType').value;
|
||||
document.querySelectorAll('.remote-type-fields').forEach(function(el) {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
const target = document.getElementById('remoteFields_' + type);
|
||||
if (target) target.style.display = '';
|
||||
|
||||
// SFTP auth_type sub-fields
|
||||
if (type === 'sftp') {
|
||||
updateSftpAuthFields();
|
||||
}
|
||||
}
|
||||
|
||||
const sftpAuthType = document.getElementById('remoteCfg_sftp_auth_type');
|
||||
if (sftpAuthType) {
|
||||
sftpAuthType.addEventListener('change', updateSftpAuthFields);
|
||||
}
|
||||
|
||||
function updateSftpAuthFields() {
|
||||
const auth = document.getElementById('remoteCfg_sftp_auth_type').value;
|
||||
document.getElementById('remoteSftpPasswordWrap').style.display = (auth === 'password') ? '' : 'none';
|
||||
document.getElementById('remoteSftpKeyWrap').style.display = (auth === 'key' || auth === 'key_passphrase') ? '' : 'none';
|
||||
document.getElementById('remoteSftpPassphraseWrap').style.display = (auth === 'key_passphrase') ? '' : 'none';
|
||||
}
|
||||
|
||||
// ---- Save remote ----
|
||||
document.getElementById('btnSaveRemote').addEventListener('click', function() {
|
||||
const type = document.getElementById('remoteType').value;
|
||||
const title = document.getElementById('remoteTitle').value.trim();
|
||||
|
||||
if (!title) {
|
||||
document.getElementById('remoteTitle').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build config object from visible fields
|
||||
const config = {};
|
||||
const prefix = fieldPrefix[type] || '';
|
||||
const fields = configFields[type] || [];
|
||||
|
||||
fields.forEach(function(f) {
|
||||
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||
if (el) {
|
||||
config[f] = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set(token, '1');
|
||||
body.set('remote_id', document.getElementById('remoteEditId').value);
|
||||
body.set('profile_id', profileId);
|
||||
body.set('remote_title', title);
|
||||
body.set('remote_type', type);
|
||||
body.set('remote_enabled', document.getElementById('remoteEnabled').checked ? '1' : '0');
|
||||
body.set('remote_keep_local', document.getElementById('remoteKeepLocal').checked ? '1' : '0');
|
||||
body.set('remote_config', JSON.stringify(config));
|
||||
|
||||
document.getElementById('btnSaveRemote').disabled = true;
|
||||
|
||||
fetch(baseUrl + 'saveRemote', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
body: body
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('btnSaveRemote').disabled = false;
|
||||
|
||||
if (data.error) {
|
||||
alert(data.message || 'Save failed');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.hide();
|
||||
loadRemotes();
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('btnSaveRemote').disabled = false;
|
||||
alert('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadRemotes();
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokosuitebackup_cpanel</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.39.01</version>
|
||||
<version>01.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user