feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
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: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m50s

#119: Manual purge — toolbar button opens modal with date picker,
AJAX count preview, confirmation before bulk delete.

#105: CPanel admin dashboard module (mod_mokosuitebackup_cpanel) —
backup status, quick action buttons per profile, next scheduled,
stats, and quick links. Registered in package manifest.

#122: 7z archive format via system 7za/7z CLI binary with optional
password encryption. New SevenZipArchiver engine class.

#98: SFTP remote file browser — custom SftpPathField with "Browse
Remote" button, modal directory listing via SSH ls, click to navigate,
double-click to select.

Also: CHANGELOG updated, wiki Home updated, #121 verified (encryption
field already visible in Archive Settings tab).

Closes #119, closes #105, closes #122, closes #98, closes #121
This commit is contained in:
Jonathan Miller
2026-06-23 13:05:42 -05:00
parent 7970597fb8
commit 899a33bc58
20 changed files with 1557 additions and 9 deletions
+4
View File
@@ -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
@@ -40,6 +40,7 @@
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
</field>
<field
name="compression_level"
@@ -291,12 +292,13 @@
/>
<field
name="sftp_path"
type="text"
type="SftpPath"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:sftp"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
@@ -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."
@@ -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."
@@ -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.
*/
@@ -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.
*/
@@ -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.
*/
@@ -0,0 +1,260 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\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);
}
}
@@ -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;
@@ -0,0 +1,253 @@
<?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 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
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
title="Browse directories on the remote SFTP server">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote
</button>
</div>
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_sftpModalLabel">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote SFTP Directory
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="{$id}_sftpStatus" class="mb-2">
<small class="text-muted">Click "Browse Remote" to connect...</small>
</div>
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
/
</div>
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
</div>
<div class="mt-2">
<small class="text-muted">
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
<br>SFTP credentials must be saved in the profile before browsing.
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
<span class="icon-checkmark" aria-hidden="true"></span>
Select This Directory
</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var fieldId = '{$id}';
var input = document.getElementById(fieldId);
var browseBtn = document.getElementById(fieldId + '_browseBtn');
var modalEl = document.getElementById(fieldId + '_sftpModal');
var treeEl = document.getElementById(fieldId + '_sftpTree');
var statusEl = document.getElementById(fieldId + '_sftpStatus');
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
var currentPath = '/';
function getProfileId() {
var el = document.getElementById('jform_id');
return el ? parseInt(el.value, 10) || 0 : 0;
}
function showModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
}
function hideModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
}
/**
* Set the status message using safe DOM methods (no innerHTML).
* @param {string} cssClass - CSS class for the small element
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
* @param {string} text - Plain text message
*/
function setStatus(cssClass, iconClass, text) {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
var small = document.createElement('small');
small.className = cssClass;
if (iconClass) {
var icon = document.createElement('span');
icon.className = iconClass;
icon.setAttribute('aria-hidden', 'true');
small.appendChild(icon);
small.appendChild(document.createTextNode(' '));
}
small.appendChild(document.createTextNode(text));
statusEl.appendChild(small);
}
function loadSftpDir(path) {
currentPath = path;
currentEl.textContent = path;
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
var profileId = getProfileId();
if (!profileId) {
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
return;
}
var form = new URLSearchParams();
form.append('task', 'ajax.browseSftpDir');
form.append('profile_id', profileId);
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) {
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data) {
if (data.error) {
setStatus('text-danger', 'icon-warning', data.message || 'Error');
return;
}
var count = data.dirs ? data.dirs.length : 0;
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
currentPath = data.current || path;
currentEl.textContent = currentPath;
renderSftpTree(data);
})
.catch(function(err) {
setStatus('text-danger', 'icon-warning', err.message);
});
}
function renderSftpTree(data) {
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
/* Parent / back button */
if (data.parent !== null && data.parent !== undefined) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' .. (parent directory)'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(data.parent);
});
list.appendChild(up);
}
/* Directory entries */
var dirs = data.dirs || [];
dirs.forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var folderIcon = document.createElement('span');
folderIcon.className = 'icon-folder';
folderIcon.setAttribute('aria-hidden', 'true');
item.appendChild(folderIcon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(dir.path);
});
/* Double-click to select and close */
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = dir.path;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
list.appendChild(item);
});
if (dirs.length === 0) {
var empty = document.createElement('div');
empty.className = 'list-group-item text-muted py-2';
empty.textContent = '(no subdirectories)';
list.appendChild(empty);
}
treeEl.appendChild(list);
}
/* Browse button click */
browseBtn.addEventListener('click', function(e) {
e.preventDefault();
var startPath = input.value.trim() || '/';
showModal();
loadSftpDir(startPath);
});
/* Select button — use the current directory */
selectBtn.addEventListener('click', function(e) {
e.preventDefault();
input.value = currentPath;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
})();
</script>
HTML;
}
}
@@ -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);
}
}
@@ -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')) {
@@ -695,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Purge Backups Modal -->
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
<?php if ($canDelete) : ?>
<div id="mb-purge-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
</h4>
<button type="button" class="btn-close mb-purge-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
<div style="padding:1.5rem;">
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
<div class="mb-3">
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
</div>
<div id="mb-purge-count-wrapper" style="display:none;">
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
</div>
<div id="mb-purge-none-wrapper" style="display:none;">
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-purge-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<?php endif; ?>
<!-- Backup Comparison Modal -->
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
@@ -863,3 +902,114 @@ $listDirn = $this->escape($this->state->get('list.direction'));
});
})();
</script>
<?php if ($canDelete) : ?>
<script>
(function() {
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
var purgeCountTimer = null;
// Intercept Purge toolbar button to show the modal
document.addEventListener('DOMContentLoaded', function() {
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
if (purgeBtn) {
purgeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Reset modal state
document.getElementById('mb-purge-date').value = '';
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
document.getElementById('mb-purge-submit').disabled = true;
document.getElementById('mb-purge-modal').style.display = 'block';
return false;
}, true);
}
// Date change triggers count lookup with debounce
var dateInput = document.getElementById('mb-purge-date');
if (dateInput) {
dateInput.addEventListener('change', function() {
if (purgeCountTimer) clearTimeout(purgeCountTimer);
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
});
}
// Close modal
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-purge-modal' || e.target.classList.contains('mb-purge-close')) {
document.getElementById('mb-purge-modal').style.display = 'none';
}
});
// Confirm on submit
var purgeForm = document.getElementById('mb-purge-form');
if (purgeForm) {
purgeForm.addEventListener('submit', function(e) {
var msg = document.getElementById('mb-purge-count-msg').textContent;
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
e.preventDefault();
}
});
}
});
function fetchPurgeCount() {
var dateVal = document.getElementById('mb-purge-date').value;
var countWrapper = document.getElementById('mb-purge-count-wrapper');
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
var countMsg = document.getElementById('mb-purge-count-msg');
var submitBtn = document.getElementById('mb-purge-submit');
if (!dateVal) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
return;
}
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
var form = new URLSearchParams();
form.append('task', 'ajax.countPurge');
form.append('date', dateVal);
form.append(PURGE_TOKEN, '1');
fetch(PURGE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
countMsg.textContent = data.message || 'Error';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
} else if (data.count === 0) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'block';
submitBtn.disabled = true;
} else {
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
countMsg.textContent = text.replace('%d', data.count);
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = false;
}
})
.catch(function(err) {
countMsg.textContent = 'Error: ' + err.message;
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
});
}
})();
</script>
<?php endif; ?>
@@ -0,0 +1,33 @@
; MokoSuiteBackup — CPanel Module language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED="MokoSuiteBackup is not installed or is disabled."
MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed"
MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet."
MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables"
MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED="Next Scheduled"
MOD_MOKOSUITEBACKUP_CPANEL_TOTAL="total"
MOD_MOKOSUITEBACKUP_CPANEL_STREAK="streak"
MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D="failed (7d)"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW="Backup Now"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS="Backup in Progress"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE="Backup Complete"
MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE="Do not navigate away or close this window while the backup is running."
MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS="View Backups"
MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT="Create Snapshot"
MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES="View Profiles"
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS="Show Backup Now Buttons"
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE="Show Next Scheduled"
@@ -0,0 +1,8 @@
; MokoSuiteBackup — CPanel Module system language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @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
-->
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokosuitebackup_cpanel</name>
<version>01.39.01</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION</description>
<namespace path="src">Joomla\Module\MokoSuiteBackupCpanel</namespace>
<files>
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.ini</language>
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="show_backup_buttons"
type="radio"
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="show_schedule"
type="radio"
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,26 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel'));
$container->registerServiceProvider(new HelperFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,72 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Module\MokoSuiteBackupCpanel\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper;
class Dispatcher extends AbstractModuleDispatcher
{
/**
* Returns the layout data for the module template.
*
* @return array
*/
protected function getLayoutData(): array
{
$data = parent::getLayoutData();
$db = Factory::getContainer()->get('DatabaseDriver');
// Status summary from the shared helper
$status = BackupStatusHelper::getStatusSummary();
// Published profiles for "Backup Now" buttons
$profiles = [];
try {
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'backup_type']))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$profiles = $db->loadObjectList() ?: [];
} catch (\Throwable $e) {
// Component may not be installed yet
}
// Next scheduled backup
$nextScheduled = null;
try {
$query = $db->getQuery(true)
->select($db->quoteName(['t.next_execution', 't.title']))
->from($db->quoteName('#__scheduler_tasks', 't'))
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile'))
->where($db->quoteName('t.state') . ' = 1')
->order($db->quoteName('t.next_execution') . ' ASC');
$db->setQuery($query, 0, 1);
$nextScheduled = $db->loadObject() ?: null;
} catch (\Throwable $e) {
// Scheduler may not exist
}
$data['status'] = $status;
$data['profiles'] = $profiles;
$data['nextScheduled'] = $nextScheduled;
return $data;
}
}
@@ -0,0 +1,254 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var array $displayData */
$status = $displayData['status'];
$profiles = $displayData['profiles'];
$nextScheduled = $displayData['nextScheduled'];
$params = $displayData['params'];
$showButtons = (int) $params->get('show_backup_buttons', 1);
$showSchedule = (int) $params->get('show_schedule', 1);
$latest = $status['latest'] ?? null;
$installed = $status['installed'] ?? false;
$totals = $status['totals'] ?? [];
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
$moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
?>
<?php if (!$installed) : ?>
<div class="alert alert-warning mb-0">
<span class="icon-warning-circle" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED'); ?>
</div>
<?php return; endif; ?>
<div id="<?php echo $moduleId; ?>" class="mod-mokosuitebackup-cpanel">
<!-- Last Backup Status -->
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-2">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP'); ?>
</h6>
<?php if ($latest) : ?>
<div class="d-flex align-items-center justify-content-between">
<div>
<span class="badge <?php echo $latest['status'] === 'complete' ? 'bg-success' : 'bg-danger'; ?>">
<?php echo $latest['status'] === 'complete'
? Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK')
: Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'); ?>
</span>
<span class="ms-1 small text-muted">
<?php echo htmlspecialchars($latest['profile'] ?? ''); ?>
</span>
</div>
<span class="small text-muted">
<?php echo HTMLHelper::_('date', $latest['backup_start'], Text::_('DATE_FORMAT_LC4')); ?>
</span>
</div>
<div class="small text-muted mt-1">
<?php echo HTMLHelper::_('number.bytes', (int) $latest['total_size']); ?>
&mdash; <?php echo Text::sprintf('MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES', (int) $latest['files_count'], (int) $latest['tables_count']); ?>
</div>
<?php else : ?>
<p class="text-muted small mb-0"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
<!-- Next Scheduled -->
<?php if ($showSchedule && $nextScheduled) : ?>
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-1">
<span class="icon-calendar" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED'); ?>
</h6>
<div class="small">
<?php echo HTMLHelper::_('date', $nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
<span class="text-muted">&mdash; <?php echo htmlspecialchars($nextScheduled->title); ?></span>
</div>
</div>
<?php endif; ?>
<!-- Stats row -->
<?php if (!empty($totals)) : ?>
<div class="d-flex gap-3 mb-3 small">
<div>
<span class="fw-bold"><?php echo (int) ($totals['all_time'] ?? 0); ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_TOTAL'); ?></span>
</div>
<div>
<span class="fw-bold text-success"><?php echo (int) ($totals['success_streak'] ?? 0); ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STREAK'); ?></span>
</div>
<?php if (($totals['recent_failed'] ?? 0) > 0) : ?>
<div>
<span class="fw-bold text-danger"><?php echo (int) $totals['recent_failed']; ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D'); ?></span>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Backup Now Buttons -->
<?php if ($showButtons && !empty($profiles)) : ?>
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-2">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW'); ?>
</h6>
<div class="d-flex flex-wrap gap-1">
<?php foreach ($profiles as $profile) : ?>
<button type="button"
class="btn btn-sm btn-outline-primary msb-cpanel-backup-btn"
data-profile-id="<?php echo (int) $profile->id; ?>"
data-module-id="<?php echo $moduleId; ?>">
<?php echo htmlspecialchars($profile->title); ?>
<span class="badge bg-secondary ms-1"><?php echo htmlspecialchars($profile->backup_type); ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Quick Links -->
<div class="list-group list-group-flush small">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots&task=snapshot.add'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=profiles'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES'); ?>
</a>
</div>
<!-- Stepped Backup Modal -->
<div id="<?php echo $moduleId; ?>-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="<?php echo $moduleId; ?>-modal-title" style="margin:0 0 1rem;"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS'); ?></h3>
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE'); ?></strong>
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="<?php echo $moduleId; ?>-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="<?php echo $moduleId; ?>-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="<?php echo $moduleId; ?>-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
</div>
<script>
(function() {
var MOD_ID = <?php echo json_encode($moduleId); ?>;
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var TOKEN = <?php echo json_encode($ajaxToken); ?>;
var running = false;
window.addEventListener('beforeunload', function(e) {
if (running) { e.preventDefault(); e.returnValue = ''; }
});
function el(id) { return document.getElementById(id); }
function showModal() {
running = true;
el(MOD_ID + '-modal').style.display = 'block';
}
function hideModal() {
running = false;
el(MOD_ID + '-modal').style.display = 'none';
}
function updateProgress(pct, msg, phase) {
var bar = el(MOD_ID + '-progress-bar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
el(MOD_ID + '-status').textContent = msg;
el(MOD_ID + '-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
var form = new URLSearchParams();
form.append(TOKEN, '1');
for (var k in params) { form.append(k, params[k]); }
var res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startBackup(profileId) {
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
var initResult = await postAjax({ task: 'ajax.init', profile_id: profileId });
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
var sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({ task: 'ajax.step', session_id: sessionId });
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
el(MOD_ID + '-modal-title').textContent = <?php echo json_encode(Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE')); ?>;
setTimeout(function() { hideModal(); location.reload(); }, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('#' + MOD_ID + ' .msb-cpanel-backup-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
startBackup(this.getAttribute('data-profile-id'));
});
});
});
})();
</script>
+1
View File
@@ -28,6 +28,7 @@
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
<file type="module" id="mod_mokosuitebackup_cpanel" client="administrator">mod_mokosuitebackup_cpanel.zip</file>
</files>
<languages>