Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php
T
Jonathan Miller 899a33bc58
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
feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
#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
2026-06-23 13:05:42 -05:00

309 lines
9.3 KiB
PHP

<?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\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
class BackupsController extends AdminController
{
protected $text_prefix = 'COM_MOKOJOOMBACKUP_BACKUPS';
public function getModel($name = 'Backup', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
/**
* Start a new backup using the specified profile.
*
* @return void
*/
public function start(): void
{
/* Accept token from both GET (profile Run button) and POST (backup form).
Joomla's checkToken() throws on failure, so try GET first. */
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$profileId = $this->input->getInt('profile_id', 1);
$description = $this->input->getString('description', '');
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'backend');
// Surface preflight warnings as Joomla messages
if (!empty($result['warnings'])) {
foreach ($result['warnings'] as $warning) {
$this->app->enqueueMessage($warning, 'warning');
}
}
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
* Download a backup archive.
*
* @return void
*/
public function download(): void
{
$this->checkToken('get');
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$id = $this->input->getInt('id', 0);
$model = $this->getModel('Backup');
$item = $model->getItem($id);
if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
// Flush any output buffers to prevent HTML mixing with binary data
while (@ob_end_clean()) {
// clear all buffers
}
$filename = basename($item->archivename);
$filesize = filesize($item->absolute_path);
// Detect content type from file extension
$contentType = str_ends_with($filename, '.tar.gz')
? 'application/gzip'
: 'application/zip';
header('Content-Type: ' . $contentType);
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename));
header('Content-Length: ' . $filesize);
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($item->absolute_path);
$this->app->close();
}
/**
* Restore from a backup record.
*
* @return void
*/
public function restore(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$id = $this->input->getInt('id', 0);
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
$password = $this->input->getString('encryption_password', '');
if (!$id) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$engine = new RestoreEngine();
$result = $engine->restore($id, $restoreFiles, $restoreDb, $preserveConfig, $password);
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$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.
*/
public function verify(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$cid = $this->input->get('cid', [], 'array');
$id = !empty($cid) ? (int) $cid[0] : $this->input->getInt('id', 0);
if (!$id) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$model = $this->getModel('Backup');
$item = $model->getItem($id);
if (!$item || !$item->id) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (!is_file($item->absolute_path)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (empty($item->checksum)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM'), 'warning');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$currentHash = hash_file('sha256', $item->absolute_path);
if ($currentHash === $item->checksum) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_OK'));
} else {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_FAILED'), 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
}