Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions[bot] 4bafaa519a chore: promote changelog [Unreleased] → [01.41.00] 2026-06-23 21:54:11 +00:00
gitea-actions[bot] 3c32bd93e9 chore(release): build 01.41.00 [skip ci] 2026-06-23 21:54:07 +00:00
jmiller ef17873448 Merge pull request 'feat: Multi-remote storage — multiple destinations per profile (#97)' (#139) from feat/multi-remote-storage into main 2026-06-23 21:53:51 +00:00
Jonathan Miller dae30161ae feat: multi-remote storage — multiple destinations per profile (#97)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
New #__mokosuitebackup_remotes table stores remote destinations with
JSON params per type (SFTP/S3/GDrive/FTP). Each profile can have
multiple enabled destinations — the engine uploads to all of them.

Database:
- New table with profile_id FK, type, enabled, params JSON, ordering
- Migration auto-converts existing profile remote columns to new table
- RemoteTable, RemoteModel, RemotesModel classes

Engine:
- BackupEngine: loadRemoteDestinations() + createUploaderFromParams()
  iterates all enabled remotes, falls back to legacy columns
- SteppedBackupEngine: one upload step per remote destination, persisted
  via session.remoteDestinations + remoteIndex
- Local copy only deleted when ALL uploads succeed

UI:
- Profile edit: "Remote Destinations" linked table with AJAX CRUD
- Add/edit modal with type selector showing dynamic fields
- Toggle enabled/disabled, delete with confirmation
- Legacy fields hidden when remotes configured, shown as fallback
- Secrets masked in responses, merged from DB on save

Closes #97
2026-06-23 16:53:08 -05:00
jmiller 8e70bfb723 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 21:52:37 +00:00
jmiller dcd772018e chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 21:52:36 +00:00
jmiller 26d765b74e chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 21:52:35 +00:00
jmiller 78b68d2647 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 21:52:34 +00:00
25 changed files with 1596 additions and 104 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.40.00
# VERSION: 01.41.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+12 -4
View File
@@ -1,6 +1,18 @@
# 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
@@ -24,7 +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
@@ -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"
@@ -495,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.40.00</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` != '';
@@ -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
@@ -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);
}
}
@@ -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.40.00</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.40.00</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.40.00</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.40.00</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.40.00</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.40.00</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.40.00</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.40.00</version>
<version>01.41.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.40.00</version>
<version>01.41.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>