diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9775608..cd3ad00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@
- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users"
- MokoRestore: auto-detect sanitized passwords and prompt for reset
- Data sanitization: passwords, emails, sessions in backup profile settings (#129)
+- Manual purge: delete all backups older than a selected date with count preview (#119)
+- CPanel admin dashboard module with backup status, quick actions, and profile buttons (#105)
+- 7z archive format via system 7za/7z binary with optional password encryption (#122)
+- SFTP remote file browser: browse remote server directories to select backup path (#98)
### Fixed
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml
index fb069b9..c0b05b5 100644
--- a/source/packages/com_mokosuitebackup/forms/profile.xml
+++ b/source/packages/com_mokosuitebackup/forms/profile.xml
@@ -40,6 +40,7 @@
>
+
diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini
index 5ab819f..8899706 100644
--- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini
+++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini
@@ -119,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
; Archive settings
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
+COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
@@ -449,6 +450,19 @@ COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
+; Purge
+COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
+COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
+COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
+COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
+COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
+COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
+COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
+COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
+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."
+
; 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."
diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini
index 2e0b135..ef2e72b 100644
--- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini
+++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini
@@ -103,3 +103,16 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
+
+; Purge
+COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
+COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
+COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
+COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
+COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
+COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
+COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
+COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
+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."
diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php
index e06593b..fb38651 100644
--- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php
+++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php
@@ -713,6 +713,57 @@ class AjaxController extends BaseController
]);
}
+ /**
+ * Count backup records that would be purged before a given date.
+ * POST: task=ajax.countPurge&date=2025-01-01
+ */
+ public function countPurge(): void
+ {
+ if (!Session::checkToken('get') && !Session::checkToken('post')) {
+ $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
+
+ return;
+ }
+
+ if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
+ $this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
+
+ return;
+ }
+
+ $date = $this->input->getString('date', '');
+
+ if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ $this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
+
+ return;
+ }
+
+ $cutoff = $date . ' 00:00:00';
+
+ try {
+ $db = \Joomla\CMS\Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__mokosuitebackup_records'))
+ ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
+ $db->setQuery($query);
+ $count = (int) $db->loadResult();
+ } catch (\Exception $e) {
+ error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
+ $this->sendJson(['error' => true, 'message' => 'Database error'], 500);
+
+ return;
+ }
+
+ $this->sendJson([
+ 'error' => false,
+ 'count' => $count,
+ 'date' => $date,
+ ]);
+ }
+
/**
* Compare two backup records side-by-side.
* POST: task=ajax.compareBackups&id1=123&id2=456
@@ -828,6 +879,184 @@ class AjaxController extends BaseController
]);
}
+ /**
+ * Browse directories on a remote SFTP server for the path picker.
+ * POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
+ */
+ public function browseSftpDir(): 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;
+ }
+
+ /* Load the profile to get SFTP credentials */
+ try {
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitebackup_profiles'))
+ ->where($db->quoteName('id') . ' = ' . $profileId);
+ $db->setQuery($query);
+ $profile = $db->loadObject();
+ } catch (\Exception $e) {
+ $this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
+
+ return;
+ }
+
+ if (!$profile) {
+ $this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
+
+ return;
+ }
+
+ $host = $profile->sftp_host ?? '';
+ $port = (int) ($profile->sftp_port ?? 22);
+ $username = $profile->sftp_username ?? '';
+ $keyData = $profile->sftp_key_data ?? '';
+ $password = $profile->sftp_password ?? '';
+
+ if (empty($host) || empty($username)) {
+ $this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
+
+ return;
+ }
+
+ if (empty($keyData) && empty($password)) {
+ $this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
+
+ return;
+ }
+
+ $requestPath = $this->input->getString('path', '/');
+
+ /* Sanitize: must start with / and not contain shell meta-characters */
+ $requestPath = '/' . ltrim($requestPath, '/');
+
+ if (preg_match('/[;&|`$<>]/', $requestPath)) {
+ $this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
+
+ return;
+ }
+
+ $keyFile = null;
+
+ try {
+ /* Write temp key if using key auth (same pattern as SftpUploader) */
+ if (!empty($keyData)) {
+ $keyContent = base64_decode($keyData, true);
+
+ if ($keyContent === false) {
+ $keyContent = $keyData;
+ }
+
+ $keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
+
+ if (file_put_contents($keyFile, $keyContent) === false) {
+ throw new \RuntimeException('Cannot write temporary SSH key file');
+ }
+
+ chmod($keyFile, 0600);
+ }
+
+ /* Build SSH command to list directories */
+ $escapedPath = escapeshellarg($requestPath);
+ $remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
+
+ $parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
+
+ if ($port !== 22) {
+ $parts[] = '-p';
+ $parts[] = (string) $port;
+ }
+
+ if ($keyFile !== null) {
+ $parts[] = '-i';
+ $parts[] = escapeshellarg($keyFile);
+ }
+
+ $parts[] = escapeshellarg($username . '@' . $host);
+ $parts[] = escapeshellarg($remoteCmd);
+
+ $cmd = implode(' ', $parts);
+
+ $output = [];
+ $exitCode = 0;
+ exec($cmd . ' 2>&1', $output, $exitCode);
+
+ /* exitCode 1 from grep means no matches (empty dir), which is OK */
+ if ($exitCode !== 0 && $exitCode !== 1) {
+ throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
+ }
+
+ /* Parse output: each line is a directory name ending with / */
+ $dirs = [];
+
+ foreach ($output as $line) {
+ $line = trim($line);
+
+ if ($line === '' || $line === './' || $line === '../') {
+ continue;
+ }
+
+ $dirName = rtrim($line, '/');
+
+ if ($dirName === '' || $dirName === '.' || $dirName === '..') {
+ continue;
+ }
+
+ $fullPath = rtrim($requestPath, '/') . '/' . $dirName;
+
+ $dirs[] = [
+ 'name' => $dirName,
+ 'path' => $fullPath,
+ ];
+ }
+
+ usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
+
+ /* Parent path */
+ $parent = null;
+
+ if ($requestPath !== '/') {
+ $parent = \dirname($requestPath);
+
+ if ($parent === '') {
+ $parent = '/';
+ }
+ }
+
+ $this->sendJson([
+ 'error' => false,
+ 'current' => $requestPath,
+ 'parent' => $parent,
+ 'dirs' => $dirs,
+ ]);
+ } catch (\Throwable $e) {
+ $this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
+ } finally {
+ if ($keyFile !== null && is_file($keyFile)) {
+ unlink($keyFile);
+ }
+ }
+ }
+
/**
* Send a JSON response and close the application.
*/
diff --git a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php
index ef63aeb..205dcd3 100644
--- a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php
+++ b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php
@@ -165,6 +165,88 @@ class BackupsController extends AdminController
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
+ /**
+ * Purge (delete) all completed backup records older than a given date.
+ *
+ * Deletes archive files, log files, and database records.
+ *
+ * @return void
+ */
+ public function purge(): void
+ {
+ $this->checkToken();
+
+ if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
+ $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
+
+ return;
+ }
+
+ $cutoffDate = $this->input->getString('purge_date', '');
+
+ if (empty($cutoffDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $cutoffDate)) {
+ $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE'), 'error');
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
+
+ return;
+ }
+
+ $cutoff = $cutoffDate . ' 00:00:00';
+
+ $db = $this->app->getContainer()->get('DatabaseDriver');
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('id'))
+ ->from($db->quoteName('#__mokosuitebackup_records'))
+ ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
+ $db->setQuery($query);
+ $ids = $db->loadColumn();
+
+ if (empty($ids)) {
+ $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'), 'warning');
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
+
+ return;
+ }
+
+ $table = $this->getModel('Backup')->getTable();
+ $deleted = 0;
+ $errors = 0;
+
+ foreach ($ids as $id) {
+ if ($table->load((int) $id)) {
+ if ($table->delete()) {
+ $deleted++;
+ } else {
+ $errors++;
+ }
+ }
+
+ $table->reset();
+ }
+
+ if ($errors > 0) {
+ $this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_PARTIAL', $deleted, $errors), 'warning');
+ } else {
+ $this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_SUCCESS', $deleted));
+ }
+
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
+ }
+
+ /**
+ * No-op target for the purge toolbar button.
+ *
+ * The toolbar button needs a task so Joomla does not complain,
+ * but the actual purge is triggered via the modal form which
+ * submits to backups.purge. This method simply redirects back.
+ */
+ public function purgeModal(): void
+ {
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
+ }
+
/**
* Verify integrity of a backup archive by re-computing SHA-256.
*/
diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php
index 09337cb..d69829f 100644
--- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php
+++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php
@@ -87,6 +87,12 @@ class BackupEngine
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
+
+ // Pass encryption password to 7z archiver (handles it natively via -p flag)
+ if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
+ $archiver->setEncryptionPassword($profile->encryption_password);
+ }
+
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
@@ -228,12 +234,14 @@ class BackupEngine
$encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) {
- if ($archiveFormat !== 'zip') {
- $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
- } else {
+ if ($archiveFormat === 'zip') {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
+ } elseif ($archiveFormat === '7z') {
+ $this->log('Archive encrypted with AES-256 (7z native encryption)');
+ } else {
+ $this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
}
}
@@ -326,7 +334,7 @@ class BackupEngine
// Write log file alongside the archive
$logContent = implode("\n", $this->log);
- $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
+ $logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
@@ -472,6 +480,7 @@ class BackupEngine
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
+ '7z' => new SevenZipArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
};
}
@@ -577,6 +586,13 @@ class BackupEngine
return;
}
+ // 7z verification via CLI
+ if ($extension === '7z') {
+ $this->verify7zArchive($archivePath);
+
+ return;
+ }
+
// ZIP verification
$zip = new \ZipArchive();
@@ -638,6 +654,64 @@ class BackupEngine
}
}
+ /**
+ * Verify a 7z archive using the CLI binary.
+ *
+ * @param string $archivePath Absolute path to the .7z file
+ *
+ * @throws \RuntimeException If the archive fails verification
+ */
+ private function verify7zArchive(string $archivePath): void
+ {
+ // Test the archive with 7z t (test integrity)
+ $candidates = PHP_OS_FAMILY === 'Windows'
+ ? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
+ : ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
+
+ $binary = null;
+
+ foreach ($candidates as $candidate) {
+ if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
+ if (is_file($candidate) && is_executable($candidate)) {
+ $binary = $candidate;
+ break;
+ }
+
+ continue;
+ }
+
+ $whichCmd = PHP_OS_FAMILY === 'Windows'
+ ? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
+ : 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
+
+ $result = trim((string) shell_exec($whichCmd));
+
+ if ($result !== '' && is_executable($result)) {
+ $binary = $result;
+ break;
+ }
+ }
+
+ if ($binary === null) {
+ // Cannot verify without the binary — log warning but don't fail
+ $this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
+
+ return;
+ }
+
+ $cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
+ $output = [];
+ $exitCode = 0;
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ throw new \RuntimeException(
+ 'Archive integrity check failed: 7z test exited with code ' . $exitCode
+ . ': ' . implode("\n", array_slice($output, -5))
+ );
+ }
+ }
+
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
diff --git a/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php b/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php
new file mode 100644
index 0000000..ae19ba1
--- /dev/null
+++ b/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php
@@ -0,0 +1,260 @@
+
+ * @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\Engine;
+
+defined('_JEXEC') or die;
+
+/**
+ * 7z archiver using the 7za/7z CLI binary.
+ *
+ * Requires p7zip-full (Linux) or 7-Zip (Windows) to be installed on the server.
+ * Supports native AES-256 encryption via the -p flag.
+ */
+class SevenZipArchiver implements ArchiverInterface
+{
+ /** @var string Absolute path to the target archive */
+ private string $archivePath = '';
+
+ /** @var string[] Absolute paths of files to add */
+ private array $filePaths = [];
+
+ /** @var string[] Corresponding local names inside the archive */
+ private array $localNames = [];
+
+ /** @var string[] Temp files created by addFromString() that must be cleaned up */
+ private array $tempFiles = [];
+
+ /** @var string Optional encryption password */
+ private string $encryptionPassword = '';
+
+ /**
+ * Set the encryption password for the archive.
+ *
+ * @param string $password Password for AES-256 encryption
+ */
+ public function setEncryptionPassword(string $password): void
+ {
+ $this->encryptionPassword = $password;
+ }
+
+ public function open(string $path): void
+ {
+ $this->archivePath = $path;
+ $this->filePaths = [];
+ $this->localNames = [];
+ $this->tempFiles = [];
+
+ // Remove existing archive to avoid appending to stale data
+ if (is_file($path)) {
+ @unlink($path);
+ }
+ }
+
+ public function addFromString(string $localName, string $contents): void
+ {
+ // Write to a temp file so 7z can read it from disk
+ $tempDir = \dirname($this->archivePath);
+ $tempFile = $tempDir . '/.7z-tmp-' . md5($localName . microtime(true)) . '-' . basename($localName);
+
+ if (file_put_contents($tempFile, $contents) === false) {
+ throw new \RuntimeException('SevenZipArchiver: cannot write temp file: ' . $tempFile);
+ }
+
+ $this->tempFiles[] = $tempFile;
+ $this->filePaths[] = $tempFile;
+ $this->localNames[] = $localName;
+ }
+
+ public function addFile(string $filePath, string $localName): void
+ {
+ $this->filePaths[] = $filePath;
+ $this->localNames[] = $localName;
+ }
+
+ public function close(): void
+ {
+ try {
+ $this->buildArchive();
+ } finally {
+ // Always clean up temp files
+ foreach ($this->tempFiles as $tempFile) {
+ if (is_file($tempFile)) {
+ @unlink($tempFile);
+ }
+ }
+
+ $this->tempFiles = [];
+ }
+ }
+
+ public function getExtension(): string
+ {
+ return '7z';
+ }
+
+ /**
+ * Build the 7z archive using the CLI binary.
+ *
+ * Writes a list file mapping local names to absolute paths, then invokes
+ * 7za/7z to create the archive. Uses stdin rename pairs for correct
+ * internal paths.
+ */
+ private function buildArchive(): void
+ {
+ $binary = $this->findBinary();
+
+ if ($binary === null) {
+ throw new \RuntimeException(
+ 'SevenZipArchiver: 7z/7za binary not found. '
+ . 'Install p7zip-full (Linux) or 7-Zip (Windows).'
+ );
+ }
+
+ if (empty($this->filePaths)) {
+ throw new \RuntimeException('SevenZipArchiver: no files to archive');
+ }
+
+ // Strategy: create a temporary staging directory with the correct
+ // directory structure, symlink or copy files, then archive the
+ // staging directory. This gives us correct internal paths.
+ $stagingDir = \dirname($this->archivePath) . '/.7z-staging-' . md5($this->archivePath . microtime(true));
+
+ if (!mkdir($stagingDir, 0755, true)) {
+ throw new \RuntimeException('SevenZipArchiver: cannot create staging directory: ' . $stagingDir);
+ }
+
+ try {
+ // Create the directory structure and link/copy files
+ foreach ($this->filePaths as $i => $sourcePath) {
+ $localName = $this->localNames[$i];
+ $targetPath = $stagingDir . '/' . $localName;
+ $targetDir = \dirname($targetPath);
+
+ if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) {
+ throw new \RuntimeException('SevenZipArchiver: cannot create directory: ' . $targetDir);
+ }
+
+ // Use symlink where possible (faster, no disk usage), fall back to copy
+ if (@symlink($sourcePath, $targetPath) === false) {
+ if (!copy($sourcePath, $targetPath)) {
+ throw new \RuntimeException('SevenZipArchiver: cannot copy file: ' . $sourcePath);
+ }
+ }
+ }
+
+ // Build command
+ $cmd = escapeshellarg($binary)
+ . ' a'
+ . ' -t7z'
+ . ' -mx=5'
+ . ' -mhe=on'
+ . ' ' . escapeshellarg($this->archivePath)
+ . ' ' . escapeshellarg($stagingDir . '/*');
+
+ // Add encryption if password is set
+ if ($this->encryptionPassword !== '') {
+ $cmd .= ' -p' . escapeshellarg($this->encryptionPassword);
+ }
+
+ // Suppress interactive prompts
+ $cmd .= ' -y';
+
+ // Redirect stderr to stdout for capture
+ $cmd .= ' 2>&1';
+
+ $output = [];
+ $exitCode = 0;
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ $outputStr = implode("\n", $output);
+ throw new \RuntimeException(
+ 'SevenZipArchiver: 7z exited with code ' . $exitCode . ': ' . $outputStr
+ );
+ }
+
+ if (!is_file($this->archivePath)) {
+ throw new \RuntimeException('SevenZipArchiver: archive was not created: ' . $this->archivePath);
+ }
+
+ // The archive contains paths relative to the staging dir.
+ // We need to verify that the internal structure doesn't include
+ // the staging dir name as a prefix. If 7z was given staging/*,
+ // the paths inside should be correct (relative to staging).
+ } finally {
+ // Remove staging directory
+ $this->removeDirectory($stagingDir);
+ }
+ }
+
+ /**
+ * Locate the 7z or 7za binary.
+ *
+ * @return string|null Absolute path to binary, or null if not found
+ */
+ private function findBinary(): ?string
+ {
+ // Check common binary names
+ $candidates = PHP_OS_FAMILY === 'Windows'
+ ? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
+ : ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
+
+ foreach ($candidates as $candidate) {
+ // If it's an absolute path, check file existence
+ if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
+ if (is_file($candidate) && is_executable($candidate)) {
+ return $candidate;
+ }
+
+ continue;
+ }
+
+ // Use 'which' / 'where' to find in PATH
+ $whichCmd = PHP_OS_FAMILY === 'Windows'
+ ? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
+ : 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
+
+ $result = trim((string) shell_exec($whichCmd));
+
+ if ($result !== '' && is_executable($result)) {
+ return $result;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Recursively remove a directory and its contents.
+ */
+ private function removeDirectory(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($items as $item) {
+ if ($item->isDir()) {
+ @rmdir($item->getPathname());
+ } else {
+ // Remove symlinks and files
+ @unlink($item->getPathname());
+ }
+ }
+
+ @rmdir($dir);
+ }
+}
diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
index 0c89b8f..990fea3 100644
--- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
+++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
@@ -81,9 +81,21 @@ class SteppedBackupEngine
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
}
- $now = date('Y-m-d H:i:s');
- $tag = $resolver->getTag();
- $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
+ $now = date('Y-m-d H:i:s');
+ $tag = $resolver->getTag();
+ $archiveFormat = $profile->archive_format ?? 'zip';
+ $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
+
+ // The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
+ // supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
+ if ($archiveFormat !== 'zip') {
+ return [
+ 'error' => true,
+ 'message' => 'The stepped backup engine only supports ZIP format. '
+ . 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
+ ];
+ }
+
$archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
diff --git a/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php b/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php
new file mode 100644
index 0000000..b501999
--- /dev/null
+++ b/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php
@@ -0,0 +1,253 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ *
+ * SFTP remote path field with Browse Remote button and modal directory browser.
+ */
+
+namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Form\FormField;
+
+class SftpPathField extends FormField
+{
+ protected $type = 'SftpPath';
+
+ protected function getInput(): string
+ {
+ $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
+ $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
+ $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
+
+ return <<
+
+
+
+
+
+
+
+
+
+ Browse Remote SFTP Directory
+
+
+
+
+
+ Click "Browse Remote" to connect...
+
+
+ /
+
+
+
+
+
+ Click a directory to navigate into it. Click "Select This Directory" to use the current path.
+ SFTP credentials must be saved in the profile before browsing.
+
+