mirror of
https://github.com/OpenXE-org/OpenXE.git
synced 2024-12-26 06:30:29 +01:00
604 lines
21 KiB
PHP
604 lines
21 KiB
PHP
<?php
|
|
|
|
namespace Xentral\Modules\Backup;
|
|
|
|
use Config;
|
|
use DateInterval;
|
|
use DateTime;
|
|
use Exception;
|
|
use Xentral\Components\Backup\Adapter\AdapterInterface;
|
|
use Xentral\Components\Backup\FileBackup;
|
|
use Xentral\Components\Backup\DatabaseBackup;
|
|
use Xentral\Components\Backup\Logger\BackupLog;
|
|
use Xentral\Components\Database\Database;
|
|
use Xentral\Components\Database\DatabaseConfig;
|
|
use Xentral\Components\Database\Exception\DatabaseExceptionInterface;
|
|
use Xentral\Modules\Backup\Exception\BackupProcessStarterException;
|
|
use ZipArchive;
|
|
use Xentral\Modules\Backup\Exception\RuntimeException;
|
|
|
|
/**
|
|
* Class BackupService
|
|
*
|
|
* @package Xentral\Modules\Backup
|
|
*/
|
|
final class BackupService
|
|
{
|
|
/**
|
|
* @var BackupGateway
|
|
*/
|
|
private $gateway;
|
|
/**
|
|
* @var DatabaseBackup
|
|
*/
|
|
private $oDbBackup;
|
|
/**
|
|
* @var FileBackup
|
|
*/
|
|
private $oFileBackup;
|
|
|
|
/** @var string */
|
|
const STATUS_FILE = 'status.txt';
|
|
|
|
/** @var string */
|
|
const SESSION_FILE = 'session.txt';
|
|
|
|
/** @var Database $db */
|
|
private $db;
|
|
|
|
/** @var BackupProcessStarterService $processStarter */
|
|
private $processStarter;
|
|
|
|
/** @var BackupSystemConfigurationService $backupSystemConfiguration */
|
|
private $backupSystemConfiguration;
|
|
|
|
/** @var BackupLog $logger */
|
|
private $logger;
|
|
|
|
/** @var int min free disk in Bytes */
|
|
const MIN_FREE_DISK = 1073741824;
|
|
|
|
private static $publicSubdirectories = ['dms', 'pdfarchiv', 'pdfmirror', 'emailbackup', 'tmp', 'uebertragung'];
|
|
|
|
/**
|
|
* BackupService constructor.
|
|
*
|
|
* @param BackupGateway $gateway
|
|
* @param DatabaseBackup $oDbBackup
|
|
* @param FileBackup $oFileBackup
|
|
* @param BackupProcessStarterService $processStarter
|
|
* @param BackupSystemConfigurationService $backupSystemConfiguration
|
|
* @param Database $database
|
|
* @param BackupLog $logger
|
|
*/
|
|
public function __construct(
|
|
BackupGateway $gateway,
|
|
DatabaseBackup $oDbBackup,
|
|
FileBackup $oFileBackup,
|
|
BackupProcessStarterService $processStarter,
|
|
BackupSystemConfigurationService $backupSystemConfiguration,
|
|
Database $database,
|
|
BackupLog $logger
|
|
) {
|
|
$this->gateway = $gateway;
|
|
$this->oDbBackup = $oDbBackup;
|
|
$this->oFileBackup = $oFileBackup;
|
|
$this->processStarter = $processStarter;
|
|
$this->backupSystemConfiguration = $backupSystemConfiguration;
|
|
$this->db = $database;
|
|
$this->logger = $logger;
|
|
}
|
|
|
|
/**
|
|
* @param string $file
|
|
*
|
|
* @return false|string
|
|
*/
|
|
protected function generateMetaData($file)
|
|
{
|
|
/** @var $adminIds */
|
|
$adminIds = $this->gateway->getAdminUserIds();
|
|
$hMeta = [
|
|
'tables' => $this->gateway->getTablesChecksum(),
|
|
'users' => !empty($adminIds) ? array_column($adminIds, 'id') : [],
|
|
'created' => time(),
|
|
'name' => $file,
|
|
];
|
|
|
|
return json_encode($hMeta);
|
|
}
|
|
|
|
/**
|
|
* @param string $file
|
|
*
|
|
* @return false|int
|
|
*/
|
|
protected function addMetadata($file)
|
|
{
|
|
$sMetaFile = $this->oDbBackup->getMetaFileName($file);
|
|
$sMeta = base64_encode($this->generateMetaData($sMetaFile));
|
|
|
|
return file_put_contents($sMetaFile, $sMeta);
|
|
}
|
|
|
|
/**
|
|
* @param Config $config
|
|
*
|
|
* @return DatabaseConfig
|
|
*/
|
|
public function convertLegacyDbConf(Config $config)
|
|
{
|
|
return new DatabaseConfig(
|
|
$config->WFdbhost,
|
|
$config->WFdbuser,
|
|
$config->WFdbpass,
|
|
$config->WFdbname,
|
|
null,
|
|
$config->WFdbport
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $backupFile
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getMySQLFileName($backupFile)
|
|
{
|
|
$asFile = explode('.', $backupFile);
|
|
array_pop($asFile);
|
|
|
|
return implode('.', $asFile) . '.sql';
|
|
}
|
|
|
|
/**
|
|
* @param Config $config
|
|
* @param string $filename
|
|
* @param array $options
|
|
* @param int|null $minimumSpace
|
|
*/
|
|
public function create(Config $config, $filename, $options = [], $minimumSpace = null)
|
|
{
|
|
if (!$this->hasExecutableExtension('zip')) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->writePersistent('Required Zip Module is missing!');
|
|
throw new RuntimeException('Required Zip Module is missing!');
|
|
}
|
|
|
|
// create mysql dump
|
|
$userPath = $config->WFuserdata;
|
|
|
|
if ($sTmpDir = $this->oFileBackup->begin($userPath)) {
|
|
$ssid = array_key_exists('ssid', $options) ? $options['ssid'] : null;
|
|
$this->logger->write('--BEGIN--');
|
|
|
|
// DELETE OLD BACKUPS ON THE FILESYSTEM (BACKWARDS AS WELL)
|
|
exec(sprintf('cd %s && ls', $this->oFileBackup->getSnapshotsDir()), $asResult);
|
|
if (is_array($asResult) && count($asResult) > 0) {
|
|
foreach ($asResult as $file) {
|
|
if (!empty($file) && $file !== $filename) {
|
|
$fullFile = sprintf('%s%s', $this->oFileBackup->getSnapshotsDir(), $file);
|
|
if (file_exists($fullFile)) {
|
|
@unlink($fullFile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (null !== $ssid) {
|
|
$this->logger->write($ssid, null, static::SESSION_FILE, false, false);
|
|
}
|
|
$sMySQLFile = $this->getMySQLFileName($filename);
|
|
$sMySQLFullPath = $sTmpDir . $sMySQLFile;
|
|
$this->logger->write('Create MySQL Dump');
|
|
|
|
if ($this->hasEnoughFreeDisk($userPath, $minimumSpace) === false) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent('Not enough free disk space for Dump creation');
|
|
throw new RuntimeException('Not enough free disk space for Dump creation');
|
|
}
|
|
|
|
$this->oDbBackup->createDump($this->convertLegacyDbConf($config), $sMySQLFullPath, null);
|
|
if (filesize($sMySQLFullPath . '.gz') > 1024) {
|
|
$this->logger->write('Create Dump meta file');
|
|
// $this->addMetadata($this->oDbBackup->getMetaFileName($this->oFileBackup->getLocalPath($filename,
|
|
// $userPath)));
|
|
}
|
|
|
|
if ($this->hasEnoughFreeDisk($userPath, $minimumSpace) === false) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent(sprintf('Not enough free disk space for %s', $userPath));
|
|
throw new RuntimeException(sprintf('Not enough free disk space for %s', $userPath));
|
|
}
|
|
|
|
// Create File Backup
|
|
$this->logger->write('Create File Backup for userdata');
|
|
$this->oFileBackup->createBackup($filename, $userPath, $sMySQLFile . '.gz');
|
|
if (filesize($this->getArchivePath($filename, $userPath)) > 1024) {
|
|
$this->logger->write('Add in Backup table');
|
|
// DELETE OLD Backup
|
|
if ($latest = $this->gateway->getLatestBackup()) {
|
|
$this->db->perform('DELETE FROM backup WHERE id=:id', ['id' => $latest['id']]);
|
|
}
|
|
$this->db->perform('INSERT INTO backup (adresse, name, dateiname, datum) VALUES (:addr,:name,:file_name,NOW())',
|
|
['addr' => $options['addr'], 'name' => $options['name'], 'file_name' => $filename]);
|
|
}
|
|
|
|
$this->logger->write('--END--');
|
|
$this->removeLoggerFiles();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param Config $config
|
|
* @param string $filename
|
|
* @param array $options
|
|
*/
|
|
public function restore(Config $config, $filename, $options = [])
|
|
{
|
|
$userPath = $config->WFuserdata;
|
|
if ($sTmpDir = $this->oFileBackup->begin($userPath)) {
|
|
$ssid = array_key_exists('ssid', $options) ? $options['ssid'] : null;
|
|
|
|
$this->logger->write('--BEGIN--');
|
|
if (null !== $ssid) {
|
|
$this->logger->write($ssid, null, static::SESSION_FILE, false, false);
|
|
}
|
|
|
|
if ($this->hasEnoughFreeDisk($userPath) === false) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent('Not enough free disk space for Backup restore');
|
|
throw new RuntimeException('Not enough free disk space for Backup restore');
|
|
}
|
|
|
|
// Replay SQL DUMP
|
|
// Backup-Tabelle extra sichern
|
|
$sBackupTmpFullPath = $sTmpDir . 'backup_temp.sql';
|
|
$this->logger->write('DUMP backup table');
|
|
$this->oDbBackup->createDump($this->convertLegacyDbConf($config), $sBackupTmpFullPath, 'backup');
|
|
$sMySQLFile = $this->getMySQLFileName($filename) . '.gz';
|
|
|
|
$FullBckPath = $this->getArchivePath($filename, $userPath);
|
|
$oZip = new ZipArchive;
|
|
$xRes = $oZip->open($FullBckPath);
|
|
$this->logger->write('Fetch Database DUMP from Backup archive');
|
|
if ($xRes !== true) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent(sprintf('Backup File "%s" cannot be unzipped!', $FullBckPath));
|
|
throw new RuntimeException(sprintf('Backup File "%s" cannot be unzipped!', $FullBckPath));
|
|
}
|
|
|
|
if ($oZip->extractTo($sTmpDir, [$sMySQLFile]) === false) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent(sprintf('SQL file not found in achieve file %s', $filename));
|
|
throw new RuntimeException(sprintf('SQL file not found in achieve file %s', $filename));
|
|
}
|
|
|
|
$oZip->close();
|
|
|
|
$asTables = $this->gateway->getTables();
|
|
$this->logger->write('DROP ALL TABLES');
|
|
foreach ($asTables as $sTable) {
|
|
$this->db->perform('DROP TABLE IF EXISTS ' . $sTable);
|
|
}
|
|
$sMySQLFullPath = $sTmpDir . $sMySQLFile;
|
|
$this->logger->write('RESTORE Database DUMP');
|
|
$this->oDbBackup->restoreDump($this->convertLegacyDbConf($config), $sMySQLFullPath);
|
|
// remove Backup Dump
|
|
$this->logger->write('REMOVE DUMP FILE');
|
|
@unlink($sMySQLFullPath);
|
|
|
|
// RESTORE Backup table
|
|
$this->logger->write('Restore Backup Table');
|
|
$this->oDbBackup->restoreDump($this->convertLegacyDbConf($config), $sBackupTmpFullPath . '.gz');
|
|
@unlink($sBackupTmpFullPath . '.gz');
|
|
|
|
$this->logger->write('Restore Backup System files');
|
|
|
|
$restoreOptions = [];
|
|
if (array_key_exists('exclude_dir', $options) && is_array($options['exclude_dir'])) {
|
|
$restoreOptions['exclude_dir'] = $options['exclude_dir'];
|
|
}
|
|
$this->oFileBackup->restoreFileSystem($filename, $userPath, $restoreOptions);
|
|
$iUserId = array_key_exists('user_id', $options) ? $options['user_id'] : 0;
|
|
if (!empty($iUserId)) {
|
|
$ssid = array_key_exists('ssid', $options) ? $options['ssid'] : null;
|
|
$ip = array_key_exists('ip', $options) ? $options['ip'] : null;
|
|
$this->logger->write('RECONNECT current User');
|
|
$this->reconnectUser($iUserId, $ssid, $ip);
|
|
}
|
|
if (array_key_exists('old_dbname', $options) && !empty($options['old_dbname'])) {
|
|
$this->migratePublicSubdirectory($options['old_dbname'], $config);
|
|
}
|
|
$this->logger->write('--END--');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getArchiveExtension()
|
|
{
|
|
return $this->oFileBackup->getBackupExtension();
|
|
}
|
|
|
|
/**
|
|
* @param string $filename
|
|
*
|
|
* @param string $userPath
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getArchivePath($filename, $userPath)
|
|
{
|
|
return $this->oFileBackup->getLocalPath($filename, $userPath);
|
|
}
|
|
|
|
/**
|
|
* @param int $iUserId
|
|
*
|
|
* @param string $ssid
|
|
*
|
|
* @param string|null $ip
|
|
*
|
|
* @return void
|
|
*/
|
|
public function reconnectUser($iUserId, $ssid, $ip = null)
|
|
{
|
|
if (isset($iUserId) && is_numeric($iUserId) && is_string($ssid)) {
|
|
$ip = null === $ip ? '127.0.0.1' : $ip;
|
|
$this->db->perform('DELETE FROM useronline WHERE user_id=:uid', ['uid' => $iUserId]);
|
|
$this->db->perform(
|
|
'INSERT INTO useronline (user_id, login, sessionid, ip, time) VALUES (:uid,1,:ssid,:ip,NOW())',
|
|
[
|
|
'uid' => $iUserId,
|
|
'ssid' => $ssid,
|
|
'ip' => $ip,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $filename
|
|
*
|
|
* @param string|null $userPath
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function getDumpMetaData($filename, $userPath = null)
|
|
{
|
|
$filePath = $this->oFileBackup->getLocalPath($filename, $userPath, true);
|
|
|
|
return $this->oDbBackup->getDumpMetaData($filePath);
|
|
}
|
|
|
|
/**
|
|
* @param string $filename
|
|
* @param string $userPath
|
|
*
|
|
* @return array
|
|
*/
|
|
public function checkSumOnAfterRecovery($filename, $userPath = null)
|
|
{
|
|
$asDiff = [];
|
|
$hDbCheckSums = $this->oDbBackup->excludeCheckSumTables($this->gateway->getTablesChecksum());
|
|
if (($xData = $this->getDumpMetaData($filename, $userPath)) && !empty($hFileCheckSums = $xData['tables'])) {
|
|
$ahFileCheckSums = $this->oDbBackup->excludeCheckSumTables($hFileCheckSums);
|
|
foreach ($ahFileCheckSums as $table => &$asFileCheckSum) {
|
|
// downward compatible
|
|
if (!is_array($asFileCheckSum)) {
|
|
$params = ['checksum', 'items'];
|
|
$values = [$asFileCheckSum, 0];
|
|
$asFileCheckSum = array_combine($params, $values);
|
|
}
|
|
if ($asFileCheckSum['checksum'] !== $hDbCheckSums[$table]['checksum'] && $asFileCheckSum['items'] !== $hDbCheckSums[$table]['items']) {
|
|
$asDiff[] = $table;
|
|
}
|
|
}
|
|
|
|
return $asDiff;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param string $xConfig Configuration options for backup
|
|
* @param string $identifier description/Title of cron action
|
|
* @param string $sParam Parameter to set in the configuration table for that action
|
|
* @param string $cronFile Cron file (in .php) located under cronjobs directory
|
|
* @param string|null $userDataDir userData directory
|
|
*
|
|
* @throws RuntimeException
|
|
* @throws Exception
|
|
* @return bool
|
|
*/
|
|
public function addToProcessStarter(
|
|
$xConfig,
|
|
$identifier = 'Backup',
|
|
$sParam = 'backup_configuration_cron',
|
|
$cronFile = 'backup',
|
|
$userDataDir = null
|
|
) {
|
|
// check if backup or restore is running?
|
|
if ($this->oDbBackup->getLockStatus() === AdapterInterface::STATUS_WORKING ||
|
|
$this->oFileBackup->getLockStatus($userDataDir) === FileBackup::STATUS_WORKING) {
|
|
return false;
|
|
}
|
|
|
|
$date = new DateTime();
|
|
$yesterday = $date->sub(new DateInterval('P1D'));
|
|
|
|
try {
|
|
$fakeLastRun = $yesterday->format('Y-m-d H:i:s');
|
|
// $this->db->perform('UPDATE prozessstarter SET aktiv=:active WHERE mutex=:mut',
|
|
// ['active' => 0, 'mut' => 0]);
|
|
// ADD NEW JOB ONLY IF THERE IS NO RUNNING JOB
|
|
try {
|
|
$xCheckPS = $this->processStarter->tryCheckProcess($cronFile, 1000, $date, $identifier);
|
|
} catch (BackupProcessStarterException $exception) {
|
|
$this->logger->writePersistent($exception->getMessage());
|
|
throw new RuntimeException($exception->getMessage());
|
|
}
|
|
|
|
$this->backupSystemConfiguration->trySetConfiguration($sParam, $xConfig);
|
|
if ($xCheckPS === false) {
|
|
$aiAffected = $this->db->fetchAffected(
|
|
'UPDATE prozessstarter SET aktiv=:active, letzteausfuerhung=:timestamp,
|
|
status=:status WHERE parameter=:cron_file',
|
|
['active' => 1, 'timestamp' => $fakeLastRun, 'cron_file' => $cronFile, 'status' => '']
|
|
);
|
|
|
|
return !empty($aiAffected);
|
|
}
|
|
} catch (DatabaseExceptionInterface $exception) {
|
|
$this->logger->writePersistent($exception->getMessage());
|
|
throw new RuntimeException($exception->getMessage());
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param null|string $fileName
|
|
*
|
|
* @return void
|
|
*/
|
|
public function removeLoggerFiles($fileName = null)
|
|
{
|
|
if (null !== $fileName && is_file($fileName)) {
|
|
$tmpBackup = $fileName;
|
|
$fileExploded = explode('.', $fileName);
|
|
$extension = array_pop($fileExploded);
|
|
if ($extension === 'zip') {
|
|
$tmpMeta = implode('.', $fileExploded) . '.meta';
|
|
if (is_file($tmpMeta)) {
|
|
unlink($tmpMeta);
|
|
}
|
|
}
|
|
unlink($tmpBackup);
|
|
}
|
|
$this->logger->delete();
|
|
$this->logger->delete(null, static::SESSION_FILE);
|
|
}
|
|
|
|
/**
|
|
* @param string $userData
|
|
* @param int|null $minFreeDisk
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function hasEnoughFreeDisk($userData, $minFreeDisk = null)
|
|
{
|
|
$rootPath = $this->getRootPathByUserDataPath($userData);
|
|
$minFreeDiskAverage = empty($minFreeDisk) || $minFreeDisk < static::MIN_FREE_DISK
|
|
? static::MIN_FREE_DISK : $minFreeDisk;
|
|
$free = disk_free_space($rootPath);
|
|
$minFree = (int)$minFreeDiskAverage;
|
|
|
|
return ($free > 0 && $free > $minFree);
|
|
}
|
|
|
|
/**
|
|
* @param string $userData
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getRootPathByUserDataPath($userData)
|
|
{
|
|
if (empty($userData)) {
|
|
$this->oFileBackup->breakCleanUp();
|
|
$this->logger->write('ERROR');
|
|
$this->logger->writePersistent(sprintf('UserData Dir "%s" is missing!', $userData));
|
|
throw new RuntimeException(sprintf('UserData Dir "%s" is missing!', $userData));
|
|
}
|
|
|
|
return dirname($userData);
|
|
}
|
|
|
|
/**
|
|
* @return bool
|
|
*/
|
|
public function isInLoginLockMode()
|
|
{
|
|
if ($this->backupSystemConfiguration->getConfiguration('login_lock_mode') === '1') {
|
|
$timeMaintenance = (int)$this->backupSystemConfiguration->getConfiguration('login_lock_mode_time');
|
|
|
|
if (empty($timeMaintenance)) {
|
|
$this->backupSystemConfiguration->trySetConfiguration('login_lock_mode_time', time());
|
|
|
|
return true;
|
|
}
|
|
|
|
$timeOutMaintenance = (int)$this->configurationService->getConfiguration('login_lock_mode_timeout');
|
|
// default 10min
|
|
$timeOut = empty($timeOutMaintenance) ? 600 : $timeOutMaintenance;
|
|
|
|
if (time() - $timeMaintenance < $timeOut) {
|
|
return true;
|
|
}
|
|
|
|
$this->backupSystemConfiguration->trySetConfiguration('login_lock_mode', 0);
|
|
$this->backupSystemConfiguration->trySetConfiguration('login_lock_mode_time', '0');
|
|
$this->backupSystemConfiguration->trySetConfiguration('login_lock_mode_timeout', '0');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string $old_dbname
|
|
* @param Config $config
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function migratePublicSubdirectory($old_dbname, Config $config)
|
|
{
|
|
$dbName = $config->WFdbname;
|
|
if ($old_dbname !== $dbName) {
|
|
$userPath = $config->WFuserdata;
|
|
$userPath = rtrim($userPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
|
foreach (self::$publicSubdirectories as $subdirectory) {
|
|
$oldDbPath = $userPath . $subdirectory . DIRECTORY_SEPARATOR . $old_dbname;
|
|
$newDbPath = $userPath . $subdirectory . DIRECTORY_SEPARATOR . $dbName;
|
|
if (is_dir($userPath . $old_dbname) && !is_dir($userPath . $subdirectory)) {
|
|
$cmd = 'mv %s %s';
|
|
@exec(sprintf($cmd, $oldDbPath, $newDbPath));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $name
|
|
*
|
|
* @throws RuntimeException
|
|
* @return bool
|
|
*/
|
|
public function hasExecutableExtension($name)
|
|
{
|
|
if (!function_exists('exec')) {
|
|
$this->logger->writePersistent('Required Function exec is missing');
|
|
throw new RuntimeException('Required Function exec is missing');
|
|
}
|
|
if (!is_string($name)) {
|
|
return false;
|
|
}
|
|
exec(sprintf('whereis %s', $name), $out);
|
|
if (empty($out)) {
|
|
return false;
|
|
}
|
|
$result = $out[0];
|
|
$resultExploded = explode(':', $result);
|
|
array_shift($resultExploded);
|
|
|
|
return !empty(trim(implode('', $resultExploded)));
|
|
}
|
|
}
|