Compare commits

..

18 Commits

Author SHA1 Message Date
gitea-actions[bot] 80c97620a5 chore: promote changelog [Unreleased] → [01.35.01] 2026-06-23 13:33:26 +00:00
gitea-actions[bot] 33d852bacf chore(release): build 01.35.01 [skip ci]
Publish to Composer / Publish Package (release) Failing after 3s
2026-06-23 13:33:24 +00:00
jmiller 8be0500913 Merge pull request 'fix: SFTP fields not showing when SFTP selected' (#95) from fix/sftp-showon into main 2026-06-23 13:32:53 +00:00
Jonathan Miller 27dded6c62 Merge remote-tracking branch 'origin/main' into fix/sftp-showon
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 / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 46s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 15s
# Conflicts:
#	.mokogitea/workflows/issue-branch.yml
#	README.md
#	source/packages/com_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml
#	source/pkg_mokosuitebackup.xml
2026-06-23 08:32:28 -05:00
gitea-actions[bot] e465dfa6ee chore(version): pre-release bump to 01.35.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 42s
2026-06-23 13:29:25 +00:00
Jonathan Miller 3ac0318ba3 fix: move SFTP fields into remote fieldset for showon to work
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 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Build & Release / Promote to RC (pull_request) Failing after 12s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 49s
Joomla's showon JS only finds the controlling field (remote_storage)
within the same rendered fieldset/tab. SFTP fields were in a separate
fieldset so they were never shown. Moved into the remote fieldset
alongside the dropdown.
2026-06-23 08:29:12 -05:00
gitea-actions[bot] 17e4625448 chore: promote changelog [Unreleased] → [01.35.00] 2026-06-23 13:22:50 +00:00
gitea-actions[bot] eb748323f7 chore(release): build 01.35.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 8s
2026-06-23 13:22:43 +00:00
jmiller bc3085f74b Merge pull request 'feat: SFTP remote storage with key file auth + CLI restore options' (#94) from feat/sftp-keyfile into main 2026-06-23 13:22:29 +00:00
Jonathan Miller f66100f74f feat: SFTP remote storage with key file auth + CLI restore options
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 / Release Readiness Check (pull_request) Failing after 7s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 13s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 34s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 27s
SFTP support:
- SftpUploader uses system scp/ssh binaries with key file auth
- Private key stored as MEDIUMTEXT in profile table (sftp_key_data)
- Key written to temp file (0600) at upload time, deleted after
- Profile form: host, port, username, password, key textarea,
  passphrase, remote path — all with showon="remote_storage:sftp"
- SQL migration for 7 new SFTP columns
- Wired into BackupEngine, SteppedBackupEngine, PreflightCheck
- API credential masking includes SFTP fields

CLI restore options:
- --files-only: restore files without touching database
- --db-only: restore database without touching files
- --no-preserve-config: overwrite configuration.php
- --password: decryption password for encrypted archives
2026-06-23 08:21:10 -05:00
jmiller be8b1f73bf chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 12:24:35 +00:00
jmiller 0f2c4fc238 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 12:24:34 +00:00
jmiller d0fe641d5c chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 12:24:32 +00:00
jmiller 4a2520a43b chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 12:24:30 +00:00
gitea-actions[bot] 54c3a6e2e9 chore: promote changelog [Unreleased] → [01.34.00] 2026-06-23 12:23:11 +00:00
gitea-actions[bot] a27ec0f0b9 chore(release): build 01.34.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-23 12:23:05 +00:00
jmiller a7c30ad67c Merge pull request 'feat: Dashboard snapshot widget, backup trend, storage breakdown (#61)' (#93) from feat/61-dashboard-widgets into main 2026-06-23 12:22:45 +00:00
Jonathan Miller ee21f7a373 feat: dashboard snapshot widget, backup trend chart, storage breakdown (#61)
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
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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 15s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 25s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3m18s
Add three new dashboard widgets:
- Snapshot widget: latest snapshot info, type badges, item counts,
  link to snapshots view, total count
- Backup trend: CSS bar chart showing daily backup sizes over 30 days,
  red bars for days with failures, tooltips with details
- Storage breakdown: horizontal bars showing space used per profile
  with color coding and backup counts

Closes #61
2026-06-23 07:22:04 -05:00
25 changed files with 625 additions and 36 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.33.00
# VERSION: 01.35.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+11 -19
View File
@@ -1,29 +1,21 @@
# Changelog
## [Unreleased]
## [01.33.00] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.33.00] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.35.00] --- 2026-06-23
## [01.35.00] --- 2026-06-23
### Added
- Backup comparison: select two backups to view side-by-side size, file count, and duration differences (#64)
- Selective article restore: browse articles inside a snapshot and restore individual items (#58)
- Archive browser: view files inside a backup without extracting (#59)
- SFTP remote storage with SSH key file authentication — key stored securely in database
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
## [01.32.00] --- 2026-06-22
## [01.34.00] --- 2026-06-23
## [01.32.00] --- 2026-06-22
## [01.34.00] --- 2026-06-23
### Added
- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62)
- Email/ntfy notifications for site restores and snapshot create/restore operations (#60)
- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56)
## [01.31.00] --- 2026-06-22
## [01.31.00] --- 2026-06-22
### Added
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
- Automatic archive integrity verification after backup creation (#65)
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
- Dashboard: snapshot widget, backup trend chart (30 days), and storage breakdown by profile (#61)
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.33.00 -->
<!-- VERSION: 01.35.01 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -124,6 +124,7 @@ class BackupsController extends ApiController
// Strip sensitive credentials before serialization
$sensitiveFields = [
'ftp_password', 'ftp_username',
'sftp_password', 'sftp_key_data', 'sftp_passphrase',
's3_access_key', 's3_secret_key',
'gdrive_client_secret', 'gdrive_refresh_token',
'encryption_password', 'ntfy_token',
@@ -160,6 +160,7 @@
>
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
</field>
@@ -174,6 +175,69 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<!-- SFTP fields (shown when remote_storage = sftp) -->
<field
name="sftp_host"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_port"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
default="22"
min="1"
max="65535"
showon="remote_storage:sftp"
/>
<field
name="sftp_username"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_key_data"
type="textarea"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
rows="6"
cols="60"
filter="raw"
showon="remote_storage:sftp"
/>
<field
name="sftp_passphrase"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:sftp"
/>
</fieldset>
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
@@ -33,6 +33,12 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS="Content Snapshots"
COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL="View All"
COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT="Latest"
COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS="No snapshots yet. Create one from the Content Snapshots view."
COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
; Backups view
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
@@ -236,7 +242,25 @@ COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been mo
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
; S3 storage
COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)"
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
; SFTP fields
COM_MOKOJOOMBACKUP_FIELDSET_SFTP="SFTP Settings"
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST="SFTP Host"
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC="SFTP server hostname or IP address"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT="SFTP Port"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Paste the contents of your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC="Directory on the remote server to upload backups to"
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.33.00</version>
<version>01.35.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -19,6 +19,13 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_key_data` MEDIUMTEXT,
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
@@ -0,0 +1,10 @@
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
@@ -453,6 +453,7 @@ class BackupEngine
{
return match ($type) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
@@ -278,6 +278,21 @@ class PreflightCheck
break;
case 'sftp':
if (empty($profile->sftp_host)) {
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
}
if (empty($profile->sftp_username)) {
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
}
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
}
break;
case 'google_drive':
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
@@ -0,0 +1,247 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* SFTP uploader using the system sftp/scp binary with SSH key authentication.
*
* The private key is stored in the database (profile column) and written
* to a temp file with 0600 permissions at upload time, then deleted.
* This avoids leaving key files on the filesystem permanently.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
class SftpUploader implements RemoteUploaderInterface
{
private string $host;
private int $port;
private string $username;
private string $keyData;
private string $passphrase;
private string $password;
private string $remotePath;
public function __construct(object $profile)
{
$this->host = $profile->sftp_host ?? '';
$this->port = (int) ($profile->sftp_port ?? 22);
$this->username = $profile->sftp_username ?? '';
$this->keyData = $profile->sftp_key_data ?? '';
$this->passphrase = $profile->sftp_passphrase ?? '';
$this->password = $profile->sftp_password ?? '';
$this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/');
}
public function upload(string $localPath, string $remoteName): array
{
if (empty($this->host)) {
return ['success' => false, 'message' => 'SFTP host is not configured'];
}
if (empty($this->username)) {
return ['success' => false, 'message' => 'SFTP username is not configured'];
}
if (empty($this->keyData) && empty($this->password)) {
return ['success' => false, 'message' => 'SFTP requires either a private key or password'];
}
$keyFile = null;
try {
/* Write key to temp file if using key auth */
if (!empty($this->keyData)) {
$keyFile = $this->writeTempKey();
}
/* Ensure remote directory exists */
$this->ensureRemoteDir($keyFile);
/* Upload via scp */
$remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName;
$cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
$errorMsg = implode("\n", $output);
throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg);
}
/* Verify upload by checking remote file size */
$remoteFile = $this->remotePath . '/' . $remoteName;
$remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile);
$localSize = filesize($localPath);
if ($remoteSize > 0 && $remoteSize !== $localSize) {
throw new \RuntimeException(
'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize
);
}
return [
'success' => true,
'message' => 'Uploaded via SFTP: ' . $remoteFile,
'remote_path' => $remoteFile,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()];
} finally {
$this->cleanupTempKey($keyFile);
}
}
public function testConnection(): array
{
if (empty($this->host)) {
return ['success' => false, 'message' => 'SFTP host is not configured'];
}
$keyFile = null;
try {
if (!empty($this->keyData)) {
$keyFile = $this->writeTempKey();
}
$cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)];
}
return [
'success' => true,
'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
} finally {
$this->cleanupTempKey($keyFile);
}
}
/**
* Write the private key from the database to a temp file with 0600 permissions.
*/
private function writeTempKey(): string
{
$tmpDir = sys_get_temp_dir();
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
if (file_put_contents($keyFile, $this->keyData) === false) {
throw new \RuntimeException('Cannot write temporary SSH key file');
}
chmod($keyFile, 0600);
return $keyFile;
}
/**
* Delete the temp key file.
*/
private function cleanupTempKey(?string $keyFile): void
{
if ($keyFile !== null && is_file($keyFile)) {
unlink($keyFile);
}
}
/**
* Ensure the remote directory exists via ssh mkdir -p.
*/
private function ensureRemoteDir(?string $keyFile): void
{
$escapedPath = escapeshellarg($this->remotePath);
$cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
/* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */
if ($exitCode !== 0) {
throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output));
}
}
/**
* Get remote file size via ssh stat.
*/
private function getRemoteFileSize(string $remotePath, ?string $keyFile): int
{
$escapedPath = escapeshellarg($remotePath);
$cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile);
$output = [];
exec($cmd . ' 2>&1', $output);
$size = (int) trim(implode('', $output));
return $size > 0 ? $size : 0;
}
/**
* Build an scp command string with proper SSH options.
*/
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
{
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
if ($this->port !== 22) {
$parts[] = '-P';
$parts[] = (string) $this->port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
if (!empty($this->passphrase)) {
/* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect.
For now, key files should be unencrypted or use ssh-agent. */
}
$parts[] = escapeshellarg($localPath);
$parts[] = escapeshellarg($remoteTarget);
return implode(' ', $parts);
}
/**
* Build an ssh command string for remote commands.
*/
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
{
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
if ($this->port !== 22) {
$parts[] = '-p';
$parts[] = (string) $this->port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
$parts[] = escapeshellarg($this->username . '@' . $this->host);
$parts[] = escapeshellarg($remoteCmd);
return implode(' ', $parts);
}
}
@@ -410,6 +410,7 @@ class SteppedBackupEngine
$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),
@@ -198,6 +198,90 @@ class DashboardModel extends BaseDatabaseModel
return false;
}
/**
* Get latest snapshot info for the dashboard widget.
*/
public function getLatestSnapshot(): ?object
{
$db = $this->getDatabase();
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('created') . ' DESC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Get snapshot count.
*/
public function getSnapshotCount(): int
{
$db = $this->getDatabase();
try {
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_snapshots'));
$db->setQuery($query);
return (int) $db->loadResult();
} catch (\Throwable $e) {
return 0;
}
}
/**
* Get backup size trend data for the last 30 days.
* Returns array of {date, total_size, count, status} grouped by day.
*/
public function getBackupTrend(): array
{
$db = $this->getDatabase();
$cutoff = date('Y-m-d', strtotime('-30 days'));
$query = $db->getQuery(true)
->select('DATE(' . $db->quoteName('backupstart') . ') AS backup_date')
->select('SUM(' . $db->quoteName('total_size') . ') AS day_size')
->select('COUNT(*) AS day_count')
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS fail_count')
->from($db->quoteName('#__mokosuitebackup_records'))
->where('DATE(' . $db->quoteName('backupstart') . ') >= ' . $db->quote($cutoff))
->group('DATE(' . $db->quoteName('backupstart') . ')')
->order('backup_date ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get storage breakdown by profile.
*/
public function getStorageByProfile(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('p.title AS profile_title')
->select('COUNT(*) AS backup_count')
->select('COALESCE(SUM(r.total_size), 0) AS total_size')
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
->group($db->quoteName('r.profile_id'))
->order('total_size DESC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get published backup profiles for the quick-action selector.
*
@@ -24,18 +24,26 @@ class HtmlView extends BaseHtmlView
public array $systemHealth = [];
public array $profiles = [];
public bool $defaultDirWarning = false;
public ?object $latestSnapshot = null;
public int $snapshotCount = 0;
public array $backupTrend = [];
public array $storageByProfile = [];
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
$this->latestSnapshot = $model->getLatestSnapshot();
$this->snapshotCount = $model->getSnapshotCount();
$this->backupTrend = $model->getBackupTrend();
$this->storageByProfile = $model->getStorageByProfile();
$this->addToolbar();
@@ -109,6 +109,122 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
});
</script>
<!-- Row 1b: Snapshot Widget -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS'); ?>
</h5>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" class="btn btn-sm btn-outline-secondary">
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL'); ?>
</a>
</div>
<div class="card-body">
<?php if ($this->latestSnapshot) : ?>
<?php $types = json_decode($this->latestSnapshot->content_types, true) ?: []; ?>
<p class="mb-1">
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT'); ?>:</strong>
<?php echo $this->escape($this->latestSnapshot->description); ?>
</p>
<p class="mb-1 text-muted">
<?php echo HTMLHelper::_('date', $this->latestSnapshot->created, Text::_('DATE_FORMAT_LC4')); ?>
&mdash;
<?php foreach ($types as $type) : ?>
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
<?php endforeach; ?>
</p>
<p class="mb-0">
<small class="text-muted">
<?php echo (int) $this->latestSnapshot->articles_count; ?> articles,
<?php echo (int) $this->latestSnapshot->categories_count; ?> categories,
<?php echo (int) $this->latestSnapshot->modules_count; ?> modules
&mdash; <?php echo $this->snapshotCount; ?> total snapshots
</small>
</p>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<!-- Storage Breakdown by Profile -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-folder-open" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN'); ?>
</h5>
</div>
<div class="card-body">
<?php if (!empty($this->storageByProfile)) : ?>
<?php
$maxSize = max(array_column($this->storageByProfile, 'total_size')) ?: 1;
$colors = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6f42c1', '#0dcaf0'];
?>
<?php foreach ($this->storageByProfile as $i => $profile) : ?>
<?php $pct = round(($profile->total_size / $maxSize) * 100); ?>
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span><?php echo $this->escape($profile->profile_title ?: 'Unknown'); ?> (<?php echo (int) $profile->backup_count; ?>)</span>
<span><?php echo HTMLHelper::_('number.bytes', $profile->total_size); ?></span>
</div>
<div style="background:#e9ecef; border-radius:3px; height:8px; overflow:hidden;">
<div style="width:<?php echo $pct; ?>%; height:100%; background:<?php echo $colors[$i % count($colors)]; ?>; border-radius:3px;"></div>
</div>
</div>
<?php endforeach; ?>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Backup Trend (30 days) -->
<?php if (!empty($this->backupTrend)) : ?>
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-chart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND'); ?>
</h5>
</div>
<div class="card-body">
<?php
$maxDaySize = max(array_column($this->backupTrend, 'day_size')) ?: 1;
?>
<div style="display:flex; align-items:flex-end; gap:2px; height:120px; overflow-x:auto;">
<?php foreach ($this->backupTrend as $day) : ?>
<?php
$barHeight = max(4, round(($day->day_size / $maxDaySize) * 100));
$barColor = $day->fail_count > 0 ? '#dc3545' : '#198754';
$tooltip = date('M j', strtotime($day->backup_date))
. ' — ' . $day->day_count . ' backup(s), '
. number_format($day->day_size / 1048576, 1) . ' MB'
. ($day->fail_count > 0 ? ', ' . $day->fail_count . ' failed' : '');
?>
<div style="flex:1; min-width:8px; max-width:24px; height:<?php echo $barHeight; ?>%; background:<?php echo $barColor; ?>; border-radius:2px 2px 0 0; cursor:default;"
title="<?php echo htmlspecialchars($tooltip); ?>"></div>
<?php endforeach; ?>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted"><?php echo date('M j', strtotime('-30 days')); ?></small>
<small class="text-muted"><?php echo date('M j'); ?></small>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Row 2: Quick Actions -->
<div class="row mb-3">
<div class="col-md-6">
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,6 +17,7 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -28,6 +29,10 @@ class RestoreCommand extends AbstractCommand
{
$this->setDescription('Restore a backup by record ID');
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
$this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)');
$this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)');
$this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php');
$this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', '');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
@@ -85,8 +90,22 @@ class RestoreCommand extends AbstractCommand
require_once $engineFile;
}
$filesOnly = $input->getOption('files-only');
$dbOnly = $input->getOption('db-only');
$preserveConfig = !$input->getOption('no-preserve-config');
$password = $input->getOption('password') ?: '';
$restoreFiles = !$dbOnly;
$restoreDb = !$filesOnly;
if ($filesOnly) {
$io->note('Restoring files only (database will not be touched)');
} elseif ($dbOnly) {
$io->note('Restoring database only (files will not be touched)');
}
$engine = new RestoreEngine();
$result = $engine->restore($recordId);
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
if ($result['success']) {
$io->success($result['message']);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</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.33.00</version>
<version>01.35.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>