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 << + + + + + +HTML; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php b/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php index 70bfe37..762ab45 100644 --- a/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php +++ b/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php @@ -272,6 +272,6 @@ HTACCESS; */ public static function logPathFromArchive(string $archivePath): string { - return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath); + return preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath); } } diff --git a/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php index 91cbfe8..1e69cb7 100644 --- a/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php @@ -128,6 +128,7 @@ class HtmlView extends BaseHtmlView if ($user->authorise('core.delete', 'com_mokosuitebackup')) { ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); + ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false); } if ($user->authorise('core.admin', 'com_mokosuitebackup')) { diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index ee6ee8e..91e8ebb 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -695,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction')); + +authorise('core.delete', 'com_mokosuitebackup'); ?> + + + +