Current File : /home/pacjaorg/www/nsa/administrator/components/com_akeebabackup/src/Model/RemotefilesModel.php |
<?php
/**
* @package akeebabackup
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Component\AkeebaBackup\Administrator\Model;
defined('_JEXEC') || die;
use Akeeba\Component\AkeebaBackup\Administrator\Model\Exceptions\FrozenRecordError;
use Akeeba\Component\AkeebaBackup\Administrator\Model\Mixin\GetErrorsFromExceptions;
use Akeeba\Engine\Factory;
use Akeeba\Engine\Platform;
use Akeeba\Engine\Postproc\Exception\RangeDownloadNotSupported;
use Exception;
use Joomla\CMS\Factory as JoomlaFactory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use RuntimeException;
#[\AllowDynamicProperties]
class RemotefilesModel extends BaseDatabaseModel
{
use GetErrorsFromExceptions;
/**
* The fragment size for chunked downloads. Default: 1MB
*/
public const DOWNLOAD_FRAGMENT_SIZE = 1048576;
/**
* Returns information about the capabilities of the post-processing engine used with a specific backup record.
*
* @param int $id
*
* @return array
* @throws Exception
*/
public function getCapabilities(int $id): array
{
$postProcEngineName = $this->getPostProcEngineNameForRecord($id, false);
if ($postProcEngineName == 'none')
{
// There's no file stored remotely. Get the post-proc engine from the profile.
$postProcEngineName = $this->getPostProcEngineNameForRecord($id, true);
}
$postProcEngine = Factory::getPostprocEngine($postProcEngineName);
return [
'engine' => $postProcEngineName,
'delete' => $postProcEngine->supportsDelete(),
'downloadToFile' => $postProcEngine->supportsDownloadToFile(),
'downloadToBrowser' => $postProcEngine->supportsDownloadToBrowser(),
'inlineDownload' => $postProcEngine->doesInlineDownloadToBrowser(),
];
}
/**
* Returns the definitions of the Manage Remotely Stored Files action for a given backup record.
*
* Returns an icon definition list for the applicable actions on this backup record
*
* @param int $id The backup record ID to return action definitions for
*
* @return array The action definitions
* @throws Exception
*/
public function getActions(int $id): array
{
$actions = [
'downloadToFile' => false,
'delete' => false,
'downloadToBrowser' => 0,
];
$postProcEngineName = $this->getPostProcEngineNameForRecord($id);
$postProcEngine = Factory::getPostprocEngine($postProcEngineName);
$stat = Platform::getInstance()->get_statistics($id);
// Does the engine support local d/l and we need to d/l the file locally?
if ($postProcEngine->supportsDownloadToFile() && !$stat['filesexist'])
{
$actions['downloadToFile'] = true;
}
// Does the engine support remote deletes?
if ($postProcEngine->supportsDelete())
{
$actions['delete'] = true;
}
// Does the engine support downloads to browser?
if ($postProcEngine->supportsDownloadToBrowser())
{
$actions['downloadToBrowser'] = max(1, $stat['multipart']);
}
return $actions;
}
/**
* Downloads a remote file back to the site's server
*
* @param int $id The backup record ID to fetch back to server
* @param int $part Which part file of the backup record should I fetch back?
* @param int $frag Which fragment of the backup record should I fetch back?
*
* @return bool true when we're done downloading, false if we have more work to do
* @throws Exception On error
*/
public function downloadToServer(int $id, int $part = -1, int $frag = -1): bool
{
// Gather the necessary information to perform the download
$backupRecord = Platform::getInstance()->get_statistics($id);
$remoteFilenameParts = explode('://', $backupRecord['remote_filename']);
$engine = Factory::getPostprocEngine($remoteFilenameParts[0]);
$remoteFilepath = $remoteFilenameParts[1];
// Note that single part archives have $backupRecord['multipart'] == 0. We need that to be 1.
$totalNumberOfParts = max($backupRecord['multipart'], 1);
// Timer initialization
$config = Factory::getConfiguration();
$timer = Factory::getTimer();
$start = $timer->getRunningTime();
$app = JoomlaFactory::getApplication();
// If we are starting a new download we need to reset the statistics in the session
if ($part == -1)
{
// Total size of the files to download
$app->getSession()->set('akeebabackup.dl_totalsize', $backupRecord['total_size']);
// Cumulative bytes downloaded so far
$app->getSession()->set('akeebabackup.dl_donesize', 0);
// Convert part -1 to 0, indicating it's the very first part
$part = 0;
// Indicate this is going to be the very first fragment of the file to download
$frag = -1;
}
while (true)
{
/**
* If we are trying to download a part that's higher than the number of parts in the archive we're all done.
*
* Remember: $part is the 0-based index (first part is zero). $totalNumberOfParts is the 1-based count of
* items in the collection.
*/
if ($part >= $totalNumberOfParts)
{
// Fall through to the return code which also updates the backup record
break;
}
// Get the remote and local filenames
$basename = basename($remoteFilepath);
$extension = strtolower(str_replace(".", "", strrchr($basename, ".")));
$partExtension = ($part == 0) ? $extension : substr($extension, 0, 1) . sprintf('%02u', $part);
$remoteFilepath = substr($remoteFilenameParts[1], 0, -strlen($extension)) . $partExtension;
$localFilepath = $config->get('akeeba.basic.output_directory') . '/' . basename($remoteFilepath);
/**
* If $frag == -1 I am starting to download a new backup archive part. Therefore I need to initialize the
* local file.
*/
if ($frag == -1)
{
Platform::getInstance()->unlink($localFilepath);
$fp = @fopen($localFilepath, 'w');
if ($fp === false)
{
throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
}
@fclose($fp);
// Set the frag to 0 to let the download proceed correctly.
$frag = 0;
}
// Calculate the offset to start downloading from and try to download the next fragment
$from = $frag * self::DOWNLOAD_FRAGMENT_SIZE;
$tempFilepath = $localFilepath . '.tmp';
$allowMultipart = true;
try
{
// Try to do a multipart download. If it's not supported, do a single part download.
try
{
$engine->downloadToFile($remoteFilepath, $tempFilepath, $from, self::DOWNLOAD_FRAGMENT_SIZE);
}
catch (RangeDownloadNotSupported $e)
{
$allowMultipart = false;
$engine->downloadToFile($remoteFilepath, $tempFilepath);
}
}
catch (Exception $e)
{
// Failed download
if (
(($part < $backupRecord['multipart']) || (($backupRecord['multipart'] == 0) && ($part == 0))) &&
($frag == 0)
)
{
// Failure to download the part's beginning = failure to download. Period.
throw new RuntimeException(Text::_('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTDOWNLOAD'), 500, $e);
}
// We tried reading past the end of a part file. Go to the next part.
$part++;
$frag = -1;
continue;
}
// Add the currently downloaded fragment's size to the running total size of downloaded files
$downloadedFragmentSize = (int) @filesize($tempFilepath);
$currentTotal = $app->getSession()->get('akeebabackup.dl_donesize', 0);
$app->getSession()->set('akeebabackup.dl_donesize', $currentTotal + $downloadedFragmentSize);
if (!$allowMultipart)
{
// Single part download: move the temporary file to the local file
$this->moveTempFile($tempFilepath, $localFilepath);
// Go to the start of the next part
$part++;
$frag = -1;
break;
}
// Multipart download: try to combine the just downloaded fragment (in a temp file) with the local file
$this->combineTemporaryAndLocalFile($tempFilepath, $localFilepath);
// Indicate we need to download the next fragment
$frag++;
// Do I have enough time to try another fragment?
$end = $timer->getRunningTime();
$requiredTime = max(1.1 * ($end - $start), !isset($requiredTime) ? 1.0 : $requiredTime);
if ($timer->getTimeLeft() < $requiredTime)
{
break;
}
$start = $end;
}
// We set these variables in the model state to allow the View to access them
$this->setState('id', $id);
$this->setState('part', $part);
$this->setState('frag', $frag);
/**
* If we are trying to download a part that's higher than the number of parts in the archive we're all done.
*
* Remember: $part is the 0-based index (first part is zero). $totalNumberOfParts is the 1-based count of
* items in the collection.
*/
if ($part >= $totalNumberOfParts)
{
// Update the backup record, indicating the files now exist locally
$backupRecord['filesexist'] = 1;
Platform::getInstance()->set_or_update_statistics($id, $backupRecord);
// Tell the called that we're all done with the downloads.
return true;
}
// Tell the caller more steps are required to download the files
return false;
}
/**
* Delete the files stored in the remote storage service
*
* @param int $id The backup record we're deleting remote stored files for
* @param int $part The backup part whose remotely stored file we're deleting
*
* @return array Information about the progress
* @throws Exception On error
*/
public function deleteRemoteFiles(int $id, int $part = -1): array
{
$ret = [
'finished' => false,
'id' => $id,
'part' => $part,
];
// Gather the necessary information to perform the delete
$stat = Platform::getInstance()->get_statistics($id);
if ($stat['frozen'])
{
throw new FrozenRecordError(Text::_('COM_AKEEBABACKUP_BUADMIN_FROZENRECORD_ERROR'));
}
$remoteFilenameParts = explode('://', $stat['remote_filename']);
$engine = Factory::getPostprocEngine($remoteFilenameParts[0]);
$remote_filename = $remoteFilenameParts[1];
// Start timing ourselves
$timer = Factory::getTimer(); // The core timer object
$start = $timer->getRunningTime(); // Mark the start of this download
$break = false; // Don't break the step
while ($timer->getTimeLeft() && !$break && ($part < $stat['multipart']))
{
// Get the remote filename
$basename = basename($remote_filename);
$extension = strtolower(str_replace(".", "", strrchr($basename, ".")));
$new_extension = $extension;
if ($part > 0)
{
$new_extension = substr($extension, 0, 1) . sprintf('%02u', $part);
}
$remote_filename = substr($remote_filename, 0, -strlen($extension)) . $new_extension;
// Do we have to initialize the process?
if ($part == -1)
{
// Init
$part = 0;
}
// Try to delete the part
$required_time = 1.0;
try
{
$engine->delete($remote_filename);
}
catch (Exception $e)
{
throw new RuntimeException(Text::_('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTDELETE'), 500, $e);
}
// Successful delete
$end = $timer->getRunningTime();
$part++;
// Do we predict that we have enough time?
$required_time = max(1.1 * ($end - $start), $required_time);
if ($timer->getTimeLeft() < $required_time)
{
$break = true;
}
$start = $end;
}
if ($part >= $stat['multipart'])
{
// Just finished!
$stat['remote_filename'] = '';
Platform::getInstance()->set_or_update_statistics($id, $stat);
$ret['finished'] = true;
return $ret;
}
// More work to do...
$ret['id'] = $id;
$ret['part'] = $part;
return $ret;
}
/**
* Appends the contents of the temporary file to the local file
*
* @param string $tempFilepath Temporary file to read from
* @param string $localFilepath Local file to append to
* @param int $chunkLength Perform the appent up to this many bytes at a time
*
* @return void
*
* @throws RuntimeException If something has gone wrong
*/
private function combineTemporaryAndLocalFile(string $tempFilepath,
string $localFilepath,
int $chunkLength = 262144)
{
try
{
$localFilePointer = @fopen($localFilepath, 'a');
if ($localFilePointer === false)
{
throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
}
$tempFilePointer = fopen($tempFilepath, 'r');
// Um, weird, I can't open the temp file.
if ($tempFilePointer === false)
{
throw new RuntimeException(sprintf('Can not read data from temporary file %s', $tempFilepath));
}
while (!feof($tempFilePointer))
{
$data = fread($tempFilePointer, $chunkLength);
if ($data === false)
{
throw new RuntimeException(sprintf('Can not read data from temporary file %s', $tempFilepath));
}
$dataLength = $this->akstrlen($data);
$written = fwrite($localFilePointer, $data);
if ($written != $dataLength)
{
throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
}
}
}
finally
{
if (isset($tempFilePointer) && is_resource($tempFilePointer))
{
fclose($tempFilePointer);
}
if (isset($localFilePointer) && is_resource($localFilePointer))
{
fclose($localFilePointer);
}
Platform::getInstance()->unlink($tempFilepath);
}
}
private function akstrlen(string $string): int
{
return function_exists('mb_strlen') ? mb_strlen($string, '8bit') : strlen($string);
}
/**
* Move a temporary file into a local file path
*
* @param string $tempFilepath The temporary file to move from
* @param string $localFilepath The local file to move into
*
* @return void
* @throws RuntimeException If the move fails
*/
private function moveTempFile(string $tempFilepath, string $localFilepath)
{
try
{
// Try to unlink the existing local file (it should exist, we already tried creating it as a zero byte file)
Platform::getInstance()->unlink($localFilepath);
// Move the temporary file to the local file
if (!Platform::getInstance()->move($tempFilepath, $localFilepath))
{
throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath));
}
}
finally
{
// Delete the temporary file
Platform::getInstance()->unlink($tempFilepath);
}
}
/**
* Returns the post-processing engine name for the given backup record ID.
*
* @param int $id The backup record ID
* @param bool $profileOverridesRecord Return the engine name from the backup profile, not the backup record
*
* @return string
* @throws Exception
*/
private function getPostProcEngineNameForRecord(int $id, bool $profileOverridesRecord = false): string
{
// Load the stats record
$stat = Platform::getInstance()->get_statistics($id);
if (empty($stat))
{
return 'none';
}
if ($profileOverridesRecord)
{
/** @var ProfilesModel $profilesModel */
$profilesModel = $this->getMVCFactory()->createModel('Profiles', 'Administrator');
$postProcPerProfile = $profilesModel->getPostProcessingEnginePerProfile();
$profileId = $stat['profile_id'];
if (!array_key_exists($profileId, $postProcPerProfile))
{
return 'none';
}
return $postProcPerProfile[$profileId];
}
// Get the post-proc engine from the remote location
$remote_filename = $stat['remote_filename'];
if (empty($remote_filename))
{
return 'none';
}
$remoteFilenameParts = explode('://', $remote_filename, 2);
return $remoteFilenameParts[0];
}
}