Compare commits

...

3 Commits

Author SHA1 Message Date
Benjamin DALSASS
05ca95a2d8 N°9556 - backup restore improvement
- cs fixer
2026-10-28 16:25:02 +01:00
Benjamin DALSASS
e1eaf3b795 N°9556 - backup restore improvement
- add php unit tests
2026-10-28 15:47:06 +01:00
Benjamin DALSASS
554d813050 N°9556 - backup restore improvement 2026-10-28 09:57:53 +01:00
2 changed files with 196 additions and 67 deletions

View File

@@ -29,6 +29,16 @@ require_once(APPROOT.'/application/application.inc.php');
require_once(APPROOT.'core/mutex.class.inc.php');
const BACKUP_DIR = APPROOT.'data/backups/';
class AjaxBackupException extends CoreException
{
public function __construct(array $aContextData = null, Exception $oPreviousException = null)
{
parent::__construct('Backup/Restore exception occurred', $aContextData, oPrevious: $oPreviousException);
}
}
/**
* @param WebPage $oPage
* @param string $sHtmlErrorMessage the whole HTML error, cinluding div/p/...
@@ -51,73 +61,79 @@ $sOperation = utils::ReadParam('operation', '');
$oPage = new AjaxPage('');
$oPage->SetContentType('text/html');
/**
* Check security
*/
switch ($sOperation) {
/**
* Can't use normal check methods (DoLogin for ex) as the datamodel can't be loaded here
* So we're only using a token generated in the restore_token operation
*/
case 'restore_exec':
IssueLog::Enable(APPROOT.'log/error.log');
if (utils::GetConfig()->Get('demo_mode')) {
DisplayErrorAndDie(
$oPage,
'<div data-error-stimulus="Error">Sorry, '.ITOP_APPLICATION_SHORT.' is in <b>demonstration mode</b>: the feature is disabled.</div>'
);
}
$sToken = utils::ReadParam('token', '', false, 'raw_data');
$sBasePath = APPROOT.'/data/';
$sTokenFile = $sBasePath.'restore.'.$sToken.'.tok';
$tokenRealPath = utils::RealPath($sTokenFile, $sBasePath);
if (($tokenRealPath === false) || (!is_file($tokenRealPath))) {
IssueLog::Error("ajax.backup.php operation=$sOperation ERROR = inexisting token $sToken");
$sEscapedToken = utils::HtmlEntities($sToken);
DisplayErrorAndDie($oPage, "<p>Error: missing token file: '$sEscapedToken'</p>");
}
break;
default:
require_once(APPROOT.'/application/startup.inc.php');
require_once(APPROOT.'/application/loginwebpage.class.inc.php');
LoginWebPage::DoLogin();
$sTransactionId = utils::ReadParam('transaction_id', '', true, 'transaction_id');
// the consumer page is not reloaded after download, we need to keep the transaction_id
$bRemoveTransactionId = ($sOperation !== 'download');
if (!utils::IsTransactionValid($sTransactionId, $bRemoveTransactionId)) {
$sEscapedOperation = utils::HtmlEntities($sOperation);
DisplayErrorAndDie($oPage, "<div data-error-stimulus=\"Error\">Error: invalid Transaction ID. The operation '$sEscapedOperation' was NOT performed!</div>");
}
ApplicationMenu::CheckMenuIdEnabled('BackupStatus');
if (utils::GetConfig()->Get('demo_mode')) {
DisplayErrorAndDie($oPage, '<div data-error-stimulus="Error">Sorry, '.ITOP_APPLICATION_SHORT.' is in <b>demonstration mode</b>: the feature is disabled.</div>');
}
break;
}
/**
* Backup from an interactive session
*/
try {
/**
* Check security
*/
switch ($sOperation) {
/**
* Can't use normal check methods (DoLogin for ex) as the datamodel can't be loaded here
* So we're only using a token generated in the restore_token operation
*/
case 'restore_exec':
IssueLog::Enable(APPROOT.'log/error.log');
if (utils::GetConfig()->Get('demo_mode')) {
DisplayErrorAndDie(
$oPage,
'<div data-error-stimulus="Error">Sorry, '.ITOP_APPLICATION_SHORT.' is in <b>demonstration mode</b>: the feature is disabled.</div>'
);
}
$sToken = utils::ReadParam('token', '', false, 'raw_data');
$sBasePath = APPROOT.'/data/';
$sTokenFile = $sBasePath.'restore.'.$sToken.'.tok';
$tokenRealPath = utils::RealPath($sTokenFile, $sBasePath);
if (($tokenRealPath === false) || (!is_file($tokenRealPath))) {
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => 'Token file not found',
'token' => $sToken,
]);
}
break;
default:
require_once(APPROOT.'/application/startup.inc.php');
require_once(APPROOT.'/application/loginwebpage.class.inc.php');
LoginWebPage::DoLogin();
$sTransactionId = utils::ReadParam('transaction_id', '', true, 'transaction_id');
// the consumer page is not reloaded after download, we need to keep the transaction_id
$bRemoveTransactionId = ($sOperation !== 'download');
if (!utils::IsTransactionValid($sTransactionId, $bRemoveTransactionId)) {
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => 'Invalid transaction id',
'transaction_id' => $sTransactionId,
]);
}
ApplicationMenu::CheckMenuIdEnabled('BackupStatus');
if (utils::GetConfig()->Get('demo_mode')) {
DisplayErrorAndDie($oPage, '<div data-error-stimulus="Error">Sorry, '.ITOP_APPLICATION_SHORT.' is in <b>demonstration mode</b>: the feature is disabled.</div>');
}
break;
}
/**
* Backup from an interactive session
*/
switch ($sOperation) {
case 'backup':
try {
set_time_limit(0);
$oBB = new BackupExec(APPROOT.'data/backups/manual/', 0 /*iRetentionCount*/);
$sRes = $oBB->Process(time() + 36000); // 10 hours to complete should be sufficient!
$oPage->output();
} catch (Exception $e) {
$oPage->p('Error: '.$e->getMessage());
IssueLog::Error($sOperation.' - '.$e->getMessage());
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => $e->getMessage(),
], $e);
}
$oPage->output();
break;
/*
@@ -128,7 +144,7 @@ try {
* As a result we're setting a token file to make sure the restore is called by an authenticated user with the correct rights !
*/
case 'restore_get_token':
$oPage = new JsonPage();
$oPage = new JsonPage() ;
$oPage->SetOutputDataOnly(true);
$sEnvironment = utils::ReadParam('environment', 'production', false, 'raw_data');
@@ -138,6 +154,15 @@ try {
}
$sFile = utils::ReadParam('file', '', false, 'raw_data');
$sBackupFilePath = utils::RealPath(BACKUP_DIR.$sFile, BACKUP_DIR);
if ($sBackupFilePath === false) {
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => 'Invalid backup file path',
'backup_file' => $sFile,
]);
}
$sToken = str_replace(' ', '', (string)microtime()).$sTransactionId;
$sTokenFile = APPROOT.'/data/restore.'.$sToken.'.tok';
file_put_contents($sTokenFile, $sFile);
@@ -171,14 +196,15 @@ try {
$oDBRS = new DBRestore($oItopConfig);
$oDBRS->SetMySQLBinDir($sMySQLBinDir);
$sBackupDir = APPROOT.'data/backups/';
$sBackupFile = $sBackupDir.$sFile;
$sBackupFile = BACKUP_DIR.$sFile;
$sRes = $oDBRS->RestoreFromCompressedBackup($sBackupFile, $sEnvironment);
IssueLog::Info('Backup Restore - Done, releasing the LOCK');
} catch (Exception $e) {
$oPage->p('Error: '.$e->getMessage());
IssueLog::Error($sOperation.' - '.$e->getMessage());
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => $e->getMessage(),
], $e);
} finally {
unlink($tokenRealPath);
}
@@ -192,14 +218,23 @@ try {
}
$sFile = utils::ReadParam('file', '', false, 'raw_data');
$oBackup = new DBBackupScheduled();
$sBackupDir = APPROOT.'data/backups/';
$sBackupFilePath = utils::RealPath($sBackupDir.$sFile, $sBackupDir);
$sBackupFilePath = utils::RealPath(BACKUP_DIR.$sFile, BACKUP_DIR);
if ($sBackupFilePath === false) {
throw new CoreUnexpectedValue('Invalid file path');
throw new AjaxBackupException([
'backup_operation' => $sOperation,
'message' => 'Invalid backup file path',
'backup_file' => $sFile,
]);
}
$oBackup->DownloadBackup($sBackupFilePath);
break;
}
} catch (Exception $e) {
IssueLog::Error($sOperation.' - '.$e->getMessage());
// exception handler
IssueLog::Error('Unable to perform backup `'.$e->getContextData()['backup_operation'].'` : Invalid backup file path', LogChannels::SECURITY, [
'backup_operation' => $e->getContextData()['backup_operation'] ?? null,
'backup_file_path' => $e->getContextData()['backup_file_path'] ?? null,
]);
http_response_code(500);
die($e->getCode());
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Combodo\iTop\Test\UnitTest\Module\ItopBackup;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use UserRights;
use utils;
class BackupAjaxEndpointTest extends ItopDataTestCase
{
public const USE_TRANSACTION = false;
private const AUTHENTICATION_PASSWORD = 'tagada-Secret,007';
public function testDownloadInvalidDownloadPathReturnsHttp500(): void
{
$sLogin = 'test-backup-endpoint-'.uniqid();
$this->CreateUser($sLogin, self::$aURP_Profiles['Administrator'], self::AUTHENTICATION_PASSWORD);
UserRights::Login($sLogin);
$this->CallItopUri(
'env-production/itop-backup/ajax.backup.php?'.http_build_query([
'operation' => 'download',
'file' => 'missing-'.uniqid().'.zip',
'auth_user' => $sLogin,
'auth_pwd' => self::AUTHENTICATION_PASSWORD,
'transaction_id' => utils::GetNewTransactionId(),
]),
[],
[
CURLOPT_HTTPHEADER => ['X-Combodo-Ajax:1'],
CURLOPT_POST => 0,
],
true
);
$this->assertSame(500, $this->aLastCurlGetInfo['http_code']);
}
public function testRestoreGetTokenInvalidTransactionIdReturnsHttp500(): void
{
$sLogin = 'test-backup-endpoint-'.uniqid();
$this->CreateUser($sLogin, self::$aURP_Profiles['Administrator'], self::AUTHENTICATION_PASSWORD);
UserRights::Login($sLogin);
$this->CallItopUri(
'env-production/itop-backup/ajax.backup.php?'.http_build_query([
'operation' => 'restore_get_token',
'auth_user' => $sLogin,
'auth_pwd' => self::AUTHENTICATION_PASSWORD,
'transaction_id' => 'unknown-transaction-id-'.uniqid(),
]),
[],
[
CURLOPT_HTTPHEADER => ['X-Combodo-Ajax:1'],
CURLOPT_POST => 0,
],
true
);
$this->assertSame(500, $this->aLastCurlGetInfo['http_code']);
}
public function testRestoreGetToken(): void
{
$sLogin = 'test-backup-endpoint-'.uniqid();
$this->CreateUser($sLogin, self::$aURP_Profiles['Administrator'], self::AUTHENTICATION_PASSWORD);
UserRights::Login($sLogin);
$sData = $this->CallItopUri(
'env-production/itop-backup/ajax.backup.php?'.http_build_query([
'operation' => 'restore_get_token',
'auth_user' => $sLogin,
'auth_pwd' => self::AUTHENTICATION_PASSWORD,
'transaction_id' => utils::GetNewTransactionId(),
]),
[],
[
CURLOPT_HTTPHEADER => ['X-Combodo-Ajax:1'],
CURLOPT_POST => 0,
],
true
);
$aData = json_decode($sData, true);
$this->assertSame(200, $this->aLastCurlGetInfo['http_code']);
$this->assertArrayHasKey('token', $aData);
}
}