2026-06-02 15:37:38 -05:00
<? php
/**
* @package MokoJoomBackup
2026-06-06 14:52:27 -05:00
* @subpackage com_mokojoombackup
2026-06-02 15:37:38 -05:00
* @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
*
* AJAX step-based backup engine for shared hosting.
*
* Each call to runStep() performs one unit of work within the PHP time
* limit, saves state, and returns. The browser JS fires the next step.
*
* This overcomes max_execution_time restrictions on shared hosting
* where ini_set() and set_time_limit() are disabled.
*/
2026-06-06 14:52:27 -05:00
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine ;
2026-06-02 15:37:38 -05:00
defined ( '_JEXEC' ) or die ;
use Joomla\CMS\Factory ;
class SteppedBackupEngine
{
/**
* Initialize a new stepped backup session.
*
* @return array{session_id: string, phase: string, progress: int, message: string}
*/
public function init ( int $profileId , string $description = '' , string $origin = 'backend' ) : array
{
$db = Factory :: getDbo ();
// Load profile
$query = $db -> getQuery ( true )
-> select ( '*' )
2026-06-06 14:52:27 -05:00
-> from ( $db -> quoteName ( '#__mokojoombackup_profiles' ))
2026-06-02 15:37:38 -05:00
-> where ( $db -> quoteName ( 'id' ) . ' = ' . $profileId );
$db -> setQuery ( $query );
$profile = $db -> loadObject ();
if ( ! $profile ) {
return [ 'error' => true , 'message' => 'Profile not found: ' . $profileId ];
}
// Create session
$session = SteppedSession :: create ();
$session -> profileId = $profileId ;
$session -> origin = $origin ;
$session -> backupType = $profile -> backup_type ;
// Parse profile settings
$session -> excludeDirs = $this -> parseNewlineList ( $profile -> exclude_dirs ?? '' );
$session -> excludeFiles = $this -> parseNewlineList ( $profile -> exclude_files ?? '' );
$session -> excludeTables = $this -> parseNewlineList ( $profile -> exclude_tables ?? '' );
2026-06-07 06:54:12 -05:00
$session -> backupDir = $profile -> backup_dir ?: '[DEFAULT_DIR]' ;
2026-06-02 15:37:38 -05:00
$session -> remoteStorage = $profile -> remote_storage ?? 'none' ;
2026-06-03 04:37:27 -05:00
$session -> includeMokoRestore = ( bool ) ( $profile -> include_mokorestore ?? false );
2026-06-02 15:37:38 -05:00
$session -> remoteKeepLocal = ( bool ) ( $profile -> remote_keep_local ?? true );
2026-06-05 00:11:46 -05:00
// Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver ( $profile );
$backupDir = $this -> resolveBackupDir ( $resolver -> resolve ( $session -> backupDir ));
2026-06-02 15:37:38 -05:00
if ( ! is_dir ( $backupDir )) {
2026-06-06 21:10:11 -05:00
if ( ! mkdir ( $backupDir , 0755 , true )) {
return [ 'error' => true , 'message' => 'Cannot create backup directory: ' . $backupDir ];
}
2026-06-02 15:37:38 -05:00
}
2026-06-07 06:54:12 -05:00
$this -> protectBackupDir ( $backupDir );
2026-06-05 00:11:46 -05:00
$now = date ( 'Y-m-d H:i:s' );
$tag = $resolver -> getTag ();
$nameFormat = $profile -> archive_name_format ?? '[host]_[datetime]_profile[profile_id]' ;
$archiveName = $resolver -> resolve ( $nameFormat ) . '.zip' ;
2026-06-02 15:37:38 -05:00
$session -> archivePath = $backupDir . '/' . $archiveName ;
$session -> archiveName = $archiveName ;
$session -> description = $description ?: ( $profile -> title . ' — ' . $now );
// Create backup record
$record = ( object ) [
'profile_id' => $profileId ,
'description' => $session -> description ,
'status' => 'running' ,
'origin' => $origin ,
'backup_type' => $profile -> backup_type ,
'archivename' => $archiveName ,
'absolute_path' => $session -> archivePath ,
'total_size' => 0 ,
'db_size' => 0 ,
'files_count' => 0 ,
'tables_count' => 0 ,
'multipart' => 0 ,
'tag' => $tag ,
'backupstart' => $now ,
'backupend' => '0000-00-00 00:00:00' ,
'filesexist' => 0 ,
'remote_filename' => '' ,
'log' => '' ,
];
2026-06-06 14:52:27 -05:00
$db -> insertObject ( '#__mokojoombackup_records' , $record , 'id' );
2026-06-02 15:37:38 -05:00
$session -> recordId = $record -> id ;
// Determine what work needs to be done and estimate steps
$totalSteps = 1 ; // init step
if ( $profile -> backup_type !== 'files' ) {
// Count tables for database phase
$tables = $this -> getSiteTables ( $session -> excludeTables );
$session -> tables = $tables ;
$totalSteps += count ( $tables ); // one step per table
}
if ( $profile -> backup_type !== 'database' ) {
// Scan files and split into batches
$scanner = new FileScanner ( JPATH_ROOT , $session -> excludeDirs , $session -> excludeFiles );
$allFiles = $scanner -> scan ();
$session -> filesCount = count ( $allFiles );
$session -> fileBatches = array_chunk ( $allFiles , $session -> batchSize );
$totalSteps += count ( $session -> fileBatches ); // one step per batch
}
$totalSteps += 1 ; // finalize step
$totalSteps += ( $session -> remoteStorage !== 'none' ) ? 1 : 0 ; // upload step
$session -> totalSteps = $totalSteps ;
$session -> currentStep = 1 ;
$session -> phase = ( $profile -> backup_type !== 'files' ) ? 'database' : 'files' ;
$session -> log ( 'Backup initialized: ' . $session -> description );
$session -> log ( 'Total steps: ' . $totalSteps . ' (tables: ' . count ( $session -> tables ) . ', file batches: ' . count ( $session -> fileBatches ) . ')' );
$session -> statusMessage = 'Initialized — starting backup...' ;
$session -> save ();
return [
'session_id' => $session -> sessionId ,
'phase' => $session -> phase ,
'progress' => $session -> getProgress (),
'message' => $session -> statusMessage ,
];
}
/**
* Run the next step of a backup session.
*
* @return array{session_id: string, phase: string, progress: int, message: string, done?: bool}
*/
public function runStep ( string $sessionId ) : array
{
$session = SteppedSession :: load ( $sessionId );
if ( ! $session ) {
return [ 'error' => true , 'message' => 'Session not found: ' . $sessionId ];
}
try {
switch ( $session -> phase ) {
case 'database' :
$this -> stepDatabase ( $session );
break ;
case 'files' :
$this -> stepFiles ( $session );
break ;
case 'finalize' :
$this -> stepFinalize ( $session );
break ;
case 'upload' :
$this -> stepUpload ( $session );
break ;
case 'complete' :
$session -> destroy ();
return [
'session_id' => $sessionId ,
'phase' => 'complete' ,
'progress' => 100 ,
'message' => 'Backup complete: ' . $session -> archiveName ,
'done' => true ,
];
}
$session -> save ();
return [
'session_id' => $sessionId ,
'phase' => $session -> phase ,
'progress' => $session -> getProgress (),
'message' => $session -> statusMessage ,
'done' => $session -> phase === 'complete' ,
];
} catch ( \Throwable $e ) {
$session -> log ( 'FATAL: ' . $e -> getMessage ());
$this -> failRecord ( $session , $e -> getMessage ());
$session -> destroy ();
return [ 'error' => true , 'message' => 'Backup failed: ' . $e -> getMessage ()];
}
}
/**
* Database phase: dump one table per step.
*/
private function stepDatabase ( SteppedSession $session ) : void
{
if ( $session -> tableIndex >= count ( $session -> tables )) {
// Database phase complete, move to files or finalize
$session -> phase = ( $session -> backupType !== 'database' ) ? 'files' : 'finalize' ;
$session -> tablesCount = $session -> tableIndex ;
$session -> log ( 'Database dump complete: ' . $session -> tablesCount . ' tables' );
return ;
}
$table = $session -> tables [ $session -> tableIndex ];
$db = Factory :: getDbo ();
// Dump this single table
$dumper = new DatabaseDumper ([]);
$sql = $this -> dumpSingleTable ( $db , $table );
// Append to a temp SQL file that will be added to ZIP in finalize
$sqlFile = $session -> archivePath . '.sql' ;
$flags = $session -> tableIndex === 0 ? 0 : FILE_APPEND ;
if ( $session -> tableIndex === 0 ) {
$header = "-- MokoJoomBackup Database Dump \n "
. "-- Generated: " . date ( 'Y-m-d H:i:s' ) . " \n "
. "-- Prefix: " . $db -> getPrefix () . " \n\n "
. "SET SQL_MODE = \" NO_AUTO_VALUE_ON_ZERO \" ; \n "
. "SET time_zone = \" +00:00 \" ; \n\n " ;
2026-06-06 21:10:11 -05:00
if ( file_put_contents ( $sqlFile , $header ) === false ) {
throw new \RuntimeException ( 'Cannot write SQL dump: ' . $sqlFile );
}
2026-06-02 15:37:38 -05:00
$flags = FILE_APPEND ;
}
2026-06-06 21:10:11 -05:00
if ( file_put_contents ( $sqlFile , $sql , $flags ) === false ) {
throw new \RuntimeException ( 'Cannot write SQL dump: ' . $sqlFile );
}
2026-06-02 15:37:38 -05:00
$session -> dbSize += strlen ( $sql );
$session -> tableIndex ++ ;
$session -> currentStep ++ ;
$session -> statusMessage = 'Dumping table ' . $session -> tableIndex . '/' . count ( $session -> tables ) . ': ' . $table ;
$session -> log ( 'Dumped table: ' . $table );
}
/**
* Files phase: add one batch of files to ZIP per step.
*/
private function stepFiles ( SteppedSession $session ) : void
{
if ( $session -> batchIndex >= count ( $session -> fileBatches )) {
$session -> phase = 'finalize' ;
$session -> log ( 'Files phase complete: ' . $session -> filesCount . ' files in ' . count ( $session -> fileBatches ) . ' batches' );
return ;
}
$batch = $session -> fileBatches [ $session -> batchIndex ];
$zip = new \ZipArchive ();
$mode = $session -> batchIndex === 0
? ( \ZipArchive :: CREATE | \ZipArchive :: OVERWRITE )
: \ZipArchive :: CREATE ;
if ( $zip -> open ( $session -> archivePath , $mode ) !== true ) {
throw new \RuntimeException ( 'Cannot open archive for writing' );
}
$added = 0 ;
foreach ( $batch as $relativePath ) {
$fullPath = JPATH_ROOT . '/' . $relativePath ;
if ( is_file ( $fullPath ) && is_readable ( $fullPath )) {
$zip -> addFile ( $fullPath , $relativePath );
$added ++ ;
}
}
$zip -> close ();
$session -> batchIndex ++ ;
$session -> currentStep ++ ;
$batchNum = $session -> batchIndex ;
$totalBatches = count ( $session -> fileBatches );
$session -> statusMessage = "Adding files batch { $batchNum } / { $totalBatches } ( { $added } files)" ;
$session -> log ( "Files batch { $batchNum } : { $added } files added" );
}
/**
2026-06-03 04:37:27 -05:00
* Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper.
2026-06-02 15:37:38 -05:00
*/
private function stepFinalize ( SteppedSession $session ) : void
{
$zip = new \ZipArchive ();
if ( $zip -> open ( $session -> archivePath , \ZipArchive :: CREATE ) !== true ) {
throw new \RuntimeException ( 'Cannot open archive for finalization' );
}
// Add database dump if it exists
$sqlFile = $session -> archivePath . '.sql' ;
if ( is_file ( $sqlFile )) {
$zip -> addFile ( $sqlFile , 'database.sql' );
}
$zip -> close ();
// Clean up temp SQL file
2026-06-07 09:11:39 -05:00
if ( is_file ( $sqlFile ) && !@ unlink ( $sqlFile )) {
error_log ( 'MokoJoomBackup: Could not delete temp SQL file: ' . $sqlFile );
2026-06-02 15:37:38 -05:00
}
$totalSize = file_exists ( $session -> archivePath ) ? filesize ( $session -> archivePath ) : 0 ;
2026-06-03 04:37:27 -05:00
// MokoRestore wrapper
if ( $session -> includeMokoRestore ) {
$session -> log ( 'Wrapping with MokoRestore script...' );
$mokoRestorePath = $session -> archivePath . '.mokorestore.zip' ;
MokoRestore :: wrap ( $session -> archivePath , $mokoRestorePath );
2026-06-02 15:37:38 -05:00
@ unlink ( $session -> archivePath );
2026-06-03 04:37:27 -05:00
rename ( $mokoRestorePath , $session -> archivePath );
2026-06-02 15:37:38 -05:00
$totalSize = filesize ( $session -> archivePath );
2026-06-03 04:37:27 -05:00
$session -> log ( 'MokoRestore archive created' );
2026-06-02 15:37:38 -05:00
}
// Update record
$db = Factory :: getDbo ();
$sizeHuman = number_format ( $totalSize / 1048576 , 2 ) . ' MB' ;
$update = ( object ) [
'id' => $session -> recordId ,
'total_size' => $totalSize ,
'db_size' => $session -> dbSize ,
'files_count' => $session -> filesCount ,
'tables_count' => $session -> tablesCount ,
'filesexist' => 1 ,
];
2026-06-06 14:52:27 -05:00
$db -> updateObject ( '#__mokojoombackup_records' , $update , 'id' );
2026-06-02 15:37:38 -05:00
$session -> currentStep ++ ;
$session -> phase = ( $session -> remoteStorage !== 'none' ) ? 'upload' : 'complete' ;
$session -> statusMessage = 'Archive finalized: ' . $sizeHuman ;
$session -> log ( 'Archive finalized: ' . $sizeHuman );
if ( $session -> phase === 'complete' ) {
$this -> completeRecord ( $session );
}
}
/**
* Upload phase: send archive to remote storage.
*/
private function stepUpload ( SteppedSession $session ) : void
{
$db = Factory :: getDbo ();
// Reload profile for remote settings
$query = $db -> getQuery ( true )
-> select ( '*' )
2026-06-06 14:52:27 -05:00
-> from ( $db -> quoteName ( '#__mokojoombackup_profiles' ))
2026-06-02 15:37:38 -05:00
-> where ( $db -> quoteName ( 'id' ) . ' = ' . $session -> profileId );
$db -> setQuery ( $query );
$profile = $db -> loadObject ();
$uploader = match ( $session -> remoteStorage ) {
'ftp' => new FtpUploader ( $profile ),
'google_drive' => new GoogleDriveUploader ( $profile ),
2026-06-06 21:10:11 -05:00
's3' => new S3Uploader ( $profile ),
2026-06-02 15:37:38 -05:00
default => throw new \InvalidArgumentException ( 'Unknown storage: ' . $session -> remoteStorage ),
};
$session -> log ( 'Starting remote upload (' . $session -> remoteStorage . ')...' );
$result = $uploader -> upload ( $session -> archivePath , $session -> archiveName );
$remoteFilename = '' ;
if ( $result [ 'success' ]) {
$remoteFilename = $result [ 'remote_path' ] ?? $session -> archiveName ;
$session -> log ( 'Remote upload complete: ' . $result [ 'message' ]);
if ( ! $session -> remoteKeepLocal && is_file ( $session -> archivePath )) {
@ unlink ( $session -> archivePath );
$session -> log ( 'Local copy removed' );
}
} else {
$session -> log ( 'WARNING: Remote upload failed: ' . $result [ 'message' ]);
}
// Update record with remote filename
$update = ( object ) [
'id' => $session -> recordId ,
'remote_filename' => $remoteFilename ,
'filesexist' => is_file ( $session -> archivePath ) ? 1 : 0 ,
];
2026-06-06 14:52:27 -05:00
$db -> updateObject ( '#__mokojoombackup_records' , $update , 'id' );
2026-06-02 15:37:38 -05:00
$session -> currentStep ++ ;
$session -> phase = 'complete' ;
$session -> statusMessage = 'Backup complete' ;
$this -> completeRecord ( $session );
}
/**
* Mark the backup record as complete.
*/
private function completeRecord ( SteppedSession $session ) : void
{
2026-06-05 00:11:46 -05:00
$db = Factory :: getDbo ();
$logContent = implode ( " \n " , $session -> log );
// Write log file alongside the archive
$logPath = preg_replace ( '/\.(zip|tar\.gz)$/i' , '.log' , $session -> archivePath );
2026-06-06 21:10:11 -05:00
if ( @ file_put_contents ( $logPath , $logContent ) === false ) {
error_log ( 'MokoJoomBackup: Could not write log file: ' . $logPath );
}
2026-06-05 00:11:46 -05:00
2026-06-02 15:37:38 -05:00
$update = ( object ) [
'id' => $session -> recordId ,
'status' => 'complete' ,
'backupend' => date ( 'Y-m-d H:i:s' ),
2026-06-05 00:11:46 -05:00
'log' => $logContent ,
2026-06-02 15:37:38 -05:00
];
2026-06-06 14:52:27 -05:00
$db -> updateObject ( '#__mokojoombackup_records' , $update , 'id' );
2026-06-02 15:37:38 -05:00
}
/**
* Mark the backup record as failed.
*/
private function failRecord ( SteppedSession $session , string $error ) : void
{
$db = Factory :: getDbo ();
$update = ( object ) [
'id' => $session -> recordId ,
'status' => 'fail' ,
'backupend' => date ( 'Y-m-d H:i:s' ),
'log' => implode ( " \n " , $session -> log ),
];
2026-06-06 14:52:27 -05:00
$db -> updateObject ( '#__mokojoombackup_records' , $update , 'id' );
2026-06-02 15:37:38 -05:00
}
/**
* Dump a single table to SQL string.
*/
private function dumpSingleTable ( object $db , string $table ) : string
{
$output = [];
$output [] = '-- --------------------------------------------------------' ;
$output [] = '-- Table: ' . $table ;
$output [] = '-- --------------------------------------------------------' ;
$output [] = '' ;
// CREATE TABLE
$db -> setQuery ( 'SHOW CREATE TABLE ' . $db -> quoteName ( $table ));
$createRow = $db -> loadRow ();
if ( ! $createRow || empty ( $createRow [ 1 ])) {
return '' ;
}
$output [] = 'DROP TABLE IF EXISTS ' . $db -> quoteName ( $table ) . ';' ;
$output [] = $createRow [ 1 ] . ';' ;
$output [] = '' ;
// Data in chunks
$db -> setQuery ( 'SELECT COUNT(*) FROM ' . $db -> quoteName ( $table ));
$rowCount = ( int ) $db -> loadResult ();
if ( $rowCount === 0 ) {
$output [] = '-- (empty table)' ;
$output [] = '' ;
return implode ( " \n " , $output );
}
$chunkSize = 500 ;
for ( $offset = 0 ; $offset < $rowCount ; $offset += $chunkSize ) {
$db -> setQuery (
$db -> getQuery ( true ) -> select ( '*' ) -> from ( $db -> quoteName ( $table )),
$offset ,
$chunkSize
);
$rows = $db -> loadAssocList ();
if ( empty ( $rows )) {
break ;
}
foreach ( $rows as $row ) {
$values = [];
foreach ( $row as $value ) {
$values [] = $value === null ? 'NULL' : $db -> quote ( $value );
}
$columns = array_map ([ $db , 'quoteName' ], array_keys ( $row ));
$output [] = 'INSERT INTO ' . $db -> quoteName ( $table )
. ' (' . implode ( ', ' , $columns ) . ')'
. ' VALUES (' . implode ( ', ' , $values ) . ');' ;
}
}
$output [] = '' ;
return implode ( " \n " , $output );
}
/**
* Get site tables (with prefix), excluding filtered tables.
*/
private function getSiteTables ( array $excludeTables ) : array
{
$db = Factory :: getDbo ();
$prefix = $db -> getPrefix ();
$tables = [];
foreach ( $db -> getTableList () as $table ) {
if ( ! str_starts_with ( $table , $prefix )) {
continue ;
}
$abstractName = '#__' . substr ( $table , strlen ( $prefix ));
$excluded = false ;
foreach ( $excludeTables as $pattern ) {
if ( $pattern === $abstractName || $pattern === $table ) {
$excluded = true ;
break ;
}
}
if ( ! $excluded ) {
$tables [] = $table ;
}
}
return $tables ;
}
2026-06-05 00:11:46 -05:00
/**
* Resolve a backup directory path. Absolute paths are used as-is,
* relative paths are resolved from JPATH_ROOT.
*/
private function resolveBackupDir ( string $dir ) : string
{
if ( $dir !== '' && ( $dir [ 0 ] === '/' || preg_match ( '#^[A-Za-z]:[/\\\\]#' , $dir ))) {
return rtrim ( $dir , '/\\' );
}
return JPATH_ROOT . '/' . $dir ;
}
2026-06-07 06:54:12 -05:00
private function protectBackupDir ( string $dir ) : void
{
$htaccess = $dir . '/.htaccess' ;
if ( ! is_file ( $htaccess )) {
2026-06-07 09:17:20 -05:00
if ( @ file_put_contents ( $htaccess , "# Apache 2.4+ \n <IfModule mod_authz_core.c> \n Require all denied \n </IfModule> \n # Apache 2.2 \n <IfModule !mod_authz_core.c> \n Order deny,allow \n Deny from all \n </IfModule> \n " ) === false ) {
2026-06-07 09:11:39 -05:00
error_log ( 'MokoJoomBackup: Could not create .htaccess in backup directory: ' . $dir );
}
2026-06-07 06:54:12 -05:00
}
$index = $dir . '/index.html' ;
if ( ! is_file ( $index )) {
2026-06-07 09:11:39 -05:00
if ( @ file_put_contents ( $index , '<!DOCTYPE html><title></title>' ) === false ) {
error_log ( 'MokoJoomBackup: Could not create index.html in backup directory: ' . $dir );
}
2026-06-07 06:54:12 -05:00
}
}
2026-06-02 15:37:38 -05:00
private function parseNewlineList ( string $text ) : array
{
if ( empty ( $text )) {
return [];
}
return array_values ( array_filter (
array_map ( 'trim' , explode ( " \n " , str_replace ( " \r " , '' , $text ))),
fn ( $line ) => $line !== ''
));
}
}