TikiWiki/tiki-manager

View on GitHub
src/Application/Restore.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
// Copyright (c) 2016, Avan.Tech, et. al.
// Copyright (c) 2008, Luis Argerich, Garland Foster, Eduardo Polidor, et. al.
// All Rights Reserved. See copyright.txt for details and a complete list of authors.
// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.

namespace TikiManager\Application;

use Symfony\Component\Filesystem\Filesystem;
use TikiManager\Application\Exception\RestoreErrorException;
use TikiManager\Config\Environment;
use TikiManager\Libs\Helpers\ApplicationHelper;
use TikiManager\Libs\VersionControl\Git;
use TikiManager\Libs\VersionControl\Svn;

class Restore extends Backup
{
    const CLONE_PROCESS = 'clone';
    const RESTORE_PROCESS = 'restore';

    protected $restoreRoot;
    protected $restoreDirname;
    protected $restoreLockFile;
    protected $process;
    protected $source;
    protected $onlyData;
    public $iniFilesToExclude = [];

    /**
     * Restore constructor.
     * @param Instance $instance
     * @param bool $direct
     * @param bool $onlyData
     * @throws Exception\FolderPermissionException
     */
    public function __construct(Instance $instance, bool $direct = false, bool $onlyData = false)
    {
        parent::__construct($instance, $direct);
        $this->onlyData = $onlyData;
        $this->restoreRoot = $instance->tempdir . DIRECTORY_SEPARATOR . 'restore';
        $this->restoreDirname = sprintf('%s-%s', $instance->getId(), $instance->name);
        $this->restoreLockFile = $instance->tempdir . DIRECTORY_SEPARATOR . 'restore.lock';
    }

    public function lock()
    {
        if ($this->access->fileExists($this->restoreLockFile)) {
            $script = sprintf("echo filemtime('%s');", $this->restoreLockFile);
            $command = $this->access->createCommand($this->instance->phpexec, ["-r {$script}"]);
            $command->run();

            $modTimestamp = trim($command->getStdoutContent());

            if ($modTimestamp &&
                is_numeric($modTimestamp) &&
                strtotime('+30 minutes', (int)$modTimestamp) > time()
            ) {
                throw new RestoreErrorException(
                    "Restore lock file found in '$this->restoreLockFile', check if there is another restore in progress or delete this file if you are not able to proceed.",
                    RestoreErrorException::LOCK_ERROR
                );
            }
        }

        $tempDir = Environment::get('TEMP_FOLDER');
        $tempLock = $tempDir . DS . 'restore.lock';

        touch($tempLock);
        $this->access->uploadFile($tempLock, $this->restoreLockFile);
        unlink($tempLock);
    }

    public function unlock()
    {
        $this->access->deleteFile($this->restoreLockFile);
    }

    public function getFolderNameFromArchive($srcArchive)
    {
        $bf = bzopen($srcArchive, 'r');
        $char = bzread($bf, 1);
        $content = $char;

        while (!feof($bf)) {
            $char = bzread($bf, 1);
            if ($char === "\0") {
                break;
            }
            $content .= $char;
        }
        bzclose($bf);

        $content = trim($content, '/');
        return $content;
    }

    public function getRestoreFolder()
    {
        return $this->getRestoreRoot() . DIRECTORY_SEPARATOR . $this->restoreDirname;
    }

    public function getRestoreRoot()
    {
        return rtrim($this->restoreRoot, '/\\');
    }

    /**
     * @param $srcArchive
     * @return string
     * @throws RestoreErrorException
     */
    public function prepareArchiveFolder($srcArchive)
    {
        $access = $this->access;
        $instance = $this->instance;
        $archivePath = $srcArchive;
        $archiveRoot = $this->getRestoreRoot();

        if ($instance->type !== 'local') {
            $archivePath = $this->uploadArchive($srcArchive);
        }

        if (!$access->fileExists($archiveRoot)) {
            $this->createRestoreRootFolder($archiveRoot);
        }

        $this->restoreDirname = $this->getFolderNameFromArchive($srcArchive);
        $restoreFolder =  $this->getRestoreFolder();

        // If the restore folder exists, remove to avoid decompression issues;
        if ($access->fileExists($restoreFolder)) {
            $this->removeRestoreFolder();
        }

        $this->decompressArchive($archiveRoot, $archivePath);

        return $restoreFolder;
    }

    public function createRestoreRootFolder($archiveRoot)
    {
        $path = $this->access->getInterpreterPath();
        $script = sprintf("echo mkdir('%s', 0777, true);", $archiveRoot);
        $command = $this->access->createCommand($path, ["-r {$script}"]);
        $command->run();

        if (empty($command->getStdoutContent())) {
            throw new RestoreErrorException(
                "Can't create '$archiveRoot': "
                . $command->getStderrContent(),
                RestoreErrorException::CREATEDIR_ERROR
            );
        }
    }

    public function readManifest($manifestPath)
    {
        $access = $this->getAccess();

        if ($this->direct && $this->source->type == 'local') {
            $access = $this->source->getBestAccess();
        }

        $webroot = rtrim($this->instance->webroot, '/\\');

        $archiveFolder = dirname($manifestPath);
        $manifest = $access->fileGetContents($manifestPath);
        $manifest = explode(PHP_EOL, $manifest);
        $manifest = array_map('trim', $manifest);
        $manifest = array_filter($manifest, 'strlen');
        $backupType = Backup::FULL_BACKUP;

        $windowsAbsolutePathsRegex = '/^([a-zA-Z]\:[\/,\\\\]).{1,}/';

        $folders = [];
        if (empty($manifest)) {
            throw new RestoreErrorException(
                "Manifest file is invalid: '{$manifestPath}'",
                RestoreErrorException::MANIFEST_ERROR
            );
        }

        foreach ($manifest as $line) {
            $values = explode('    ', $line);
            switch (count($values)) {
                case 2:
                    list($type, $destination) = $values;
                    break;
                case 3:
                    list($hash, $type, $destination) = $values;
                    break;
                case 4:
                    list($hash, $type, $destination, $backupType) = $values;
                    break;
            }

            if ($type == 'conf_local') {
                continue;
            }

            if ($this->onlyData && $type !== 'data') {
                continue;
            }

            if ($this->direct) {
                $source = ($type === 'app') ? $destination : $this->getSourceInstance()->getWebPath($destination);
            } else {
                $source = $archiveFolder . DIRECTORY_SEPARATOR . $hash;
                $source .= $type != 'conf_external' ? DIRECTORY_SEPARATOR . basename($destination) : '';
            }

            $windowsAbsolutePaths = (preg_match($windowsAbsolutePathsRegex, $destination, $matches)) ? true : false;

            if ($destination[0] === '/' || $windowsAbsolutePaths) {
                if ($type === 'app') {
                    $destination = '';
                } else {
                    $this->io->warning("{Skipping {$destination}. Path shouldn't have absolute paths, to avoid override data.");
                    continue;
                }
            }

            $destination = $webroot . DIRECTORY_SEPARATOR . $destination;
            $destination = ApplicationHelper::getAbsolutePath($destination);

            $folders[] = [
                $type,
                $source,
                $destination,
                $backupType == Backup::FULL_BACKUP,
            ];
        }
        return $folders;
    }

    /**
     * @param string $srcContent
     * @throws RestoreErrorException
     */
    public function restoreFiles(string $srcContent)
    {
        if (is_dir($srcContent)) {
            $this->restoreFilesFromFolder($srcContent);
        } elseif (is_file($srcContent)) {
            $this->restoreFilesFromArchive($srcContent);
        }
    }

    /**
     * @param string $srcArchive
     * @throws RestoreErrorException
     */
    protected function restoreFilesFromArchive(string $srcArchive)
    {
        $srcFolder = $this->prepareArchiveFolder($srcArchive);
        return $this->restoreFilesFromFolder($srcFolder);
    }

    protected function restoreFilesFromFolder(string $srcFolder)
    {
        $manifest = "{$srcFolder}/manifest.txt";
        $folders = $this->readManifest($manifest);

        $this->setIniFilesToExclude($manifest);

        foreach ($folders as $folder) {
            list($type, $src, $target, $isFull) = $folder;

            // system configuration file
            if ($type == 'conf_external') {
                if ($this->isSSHToLocal()) {
                    $this->getSourceInstance()->getBestAccess()->downloadFile($src, $target);
                } else {
                    $this->getAccess()->uploadFile($src, $target);
                }

                continue;
            }

            if ($type == 'app' && !$isFull) {
                $this->restoreFromVCS($src, $target);
            }

            $this->restoreFolder($src, $target, $isFull);
        }

        if (! $this->onlyData) {
            $changes = "{$srcFolder}/changes.txt";
            $this->applyChanges($changes);
        }
    }

    public function restoreFolder($src, $target, $isFull = false)
    {
        $access = $this->getAccess();
        $instance = $this->instance;
        $src = rtrim($src, '/\\');
        $target = rtrim($target, '/\\');

        if (empty($src) || empty($target)) {
            throw new RestoreErrorException(
                "Invalid paths:\n \$src='$src';\n \$target='$target';",
                RestoreErrorException::INVALID_PATHS
            );
        }

        $path = $this->access->getInterpreterPath();
        $script = sprintf("if (!is_dir('%s')) { echo mkdir('%s', 0777, true); };", $target, $target);

        $command = $access->createCommand($path, ["-r {$script}"]);
        $command->run();

        if ($command->getReturn() !== 0) {
            throw new RestoreErrorException(
                "Can't create target folder '$target': "
                . $command->getStderrContent(),
                RestoreErrorException::CREATEDIR_ERROR
            );
        }

        if (ApplicationHelper::isWindows() && $instance->type == 'local') {
            $toExclude = [
                $src . DIRECTORY_SEPARATOR . '.htaccess',
                $src . DIRECTORY_SEPARATOR . 'maintenance.php',
                $src . DIRECTORY_SEPARATOR . 'db' . DIRECTORY_SEPARATOR . 'local.php',
            ];

            if ($this->direct) {
                $toExclude[] = $src . DIRECTORY_SEPARATOR . 'temp' . DIRECTORY_SEPARATOR . '*';
            }

            if ($this->getProcess() == self::CLONE_PROCESS && !empty($this->iniFilesToExclude)) {
                foreach ($this->iniFilesToExclude as $iniFile) {
                    $toExclude[] = $src . DIRECTORY_SEPARATOR . $iniFile;
                }
            }

            $host = $this->access->getHost();
            $returnVal = $host->windowsSync(
                $src,
                $target,
                null,
                $toExclude
            );

            if ($returnVal > 8) {
                throw new RestoreErrorException(
                    "Failed copying '$src' to '$target': Robocopy error code " . $returnVal,
                    RestoreErrorException::COPY_ERROR
                );
            }

            if ($access->fileExists($src . '/.htaccess')) {
                $host->sendFile(
                    $src . DIRECTORY_SEPARATOR . '.htaccess',
                    $target . DIRECTORY_SEPARATOR . '.htaccess' . ($instance->isLocked() ? '.bak' : '')
                );
            }
        } else {
            $rsyncFlags = [
                '-a',
                $isFull ? '--delete' : '--force'
            ];

            $rsyncExcludes = [
                '--exclude',
                '/.htaccess',
                '--exclude',
                '/maintenance.php',
                '--exclude',
                '/db/local.php'
            ];

            if ($this->direct) {
                // Sync options for the temp folder
                $rsyncExcludes = array_merge($rsyncExcludes, [
                    '--include=temp/**',
                    '--include=.gitkeep',
                    '--include=index.php',
                    '--include=.htaccess',
                    '--include=README',
                    '--exclude=temp/**/*',
                    '--exclude=temp/*',
                ]);
            }

            if ($this->getProcess() == self::CLONE_PROCESS && !empty($this->iniFilesToExclude)) {
                foreach ($this->iniFilesToExclude as $iniFile) {
                    $rsyncExcludes[] = '--exclude';
                    $rsyncExcludes[] = $iniFile;
                }
            }

            $accessToRestore = $access;

            if ($localToSSH = $this->isLocalToSSH()) {
                $sshPort = $access->port;
                $target = $access->getRsyncPrefix() . $target;
                $access = $this->source->getBestAccess();
            }

            if ($sshToLocal = $this->isSSHToLocal()) {
                $sourceAccess = $this->getSourceInstance()->getBestAccess();
                $sshPort = $sourceAccess->port;
                $rsyncPrefix = $sourceAccess->getRsyncPrefix();
                $src = $rsyncPrefix . $src;
            }

            if ($localToSSH || $sshToLocal) {
                $rsyncFlags[] = '-e';
                $rsyncFlags[] = 'ssh -p ' . ($sshPort ?? 22) .' -i ' . Environment::get('SSH_KEY');
            }

            $rsyncFolders = [
                $src . '/',
                $target . '/'
            ];

            $rsyncContent = array_merge($rsyncFlags, $rsyncExcludes, $rsyncFolders);

            $command = $access->createCommand('rsync');
            $command->setArgs(
                $rsyncContent
            );
            $command->run();

            if ($command->getReturn() !== 0) {
                throw new RestoreErrorException(
                    "Failed copying '$src' to '$target': "
                    . $command->getStderrContent(),
                    RestoreErrorException::COPY_ERROR
                );
            }

            $access = $accessToRestore;

            if ($access->fileExists($src . '/.htaccess')) {
                $command = $access->createCommand('rsync');
                $command->setArgs([
                    $src . '/.htaccess',
                    $target . '/.htaccess' . ($instance->isLocked() ? '.bak' : '')
                ]);
                $command->run();
            }
        }

        return true;
    }

    public function uploadArchive($srcArchive)
    {
        $access = $this->access;
        $instance = $this->instance;

        $basename = basename($srcArchive);
        $remote = $instance->getWorkPath($basename);
        $access->uploadFile($srcArchive, $remote);
        return $remote;
    }

    /**
     * Decompress a bzip2 file into a given folder
     *
     * @param $folder
     * @param $archive
     * @throws RestoreErrorException
     */
    public function decompressArchive($folder, $archive)
    {
        $access = $this->access;

        $bzipStep = false;
        $tarFlags = '-xpj';
        if (ApplicationHelper::isWindows()) {
            $bzipStep = true;
            $tarFlags = '-xp';
            $archive = str_replace('/', DIRECTORY_SEPARATOR, $archive);
        }

        if ($bzipStep) {
            $args = ['-dk', $archive];
            $command = $access->createCommand('bzip2', $args);
            $command->run();

            if ($command->getReturn() !== 0) {
                throw new RestoreErrorException(
                    "Can't extract '$archive' to '$folder': "
                    . $command->getStderrContent(),
                    RestoreErrorException::DECOMPRESS_ERROR
                );
            }

            $archive = preg_replace('/.bz2$/', '', $archive);
        }

        $args = [$tarFlags, '-C', $folder, '-f', $archive];
        $command = $access->createCommand('tar', $args);
        $command->run();

        if ($command->getReturn() !== 0) {
            throw new RestoreErrorException(
                "Can't extract '$archive' to '$folder': "
                . $command->getStderrContent(),
                RestoreErrorException::DECOMPRESS_ERROR
            );
        }

        if ($bzipStep && file_exists($archive)) {
            unlink($archive);
        }
    }

    /**
     * @param $process
     */
    public function setProcess($process)
    {
        $this->process = $process ? self::CLONE_PROCESS : self::RESTORE_PROCESS;
    }

    /**
     * @return mixed
     */
    public function getProcess()
    {
        return $this->process;
    }

    /**
     * Set the source associated to this restore instance
     * @param Instance $source
     */
    public function setSourceInstance(Instance $source): void
    {
        $this->source = $source;
    }

    /**
     * Get the source associated to this restore instance
     * @return Instance|null
     */
    public function getSourceInstance()
    {
        return $this->source;
    }

    /**
     * @param $manifest_file
     */
    public function setIniFilesToExclude($manifest)
    {

        $system_config_file_info = $this->readSystemIniConfigFileFromManifest($manifest);

        if ($this->getProcess() == self::CLONE_PROCESS) {
            // src
            if (isset($system_config_file_info['location']) && $system_config_file_info['location'] == 'local') {
                $this->iniFilesToExclude[] = $system_config_file_info['file'];
            }

            // remote
            $remoteSystemConfgFilePath = $this->app->getSystemIniConfigFilePath();
            if (!empty($remoteSystemConfgFilePath)) {
                $parts = explode('||', $remoteSystemConfgFilePath);
                if (isset($parts[0]) && isset($parts[1]) && $parts[1] == 'local') {
                    $this->iniFilesToExclude[] = $parts[0];
                }
            }
        }
    }

    /**
     * @param $manifest_file
     * @return array
     */
    public function readSystemIniConfigFileFromManifest($manifest_file)
    {
        $result = [];
        $access = $this->getAccess();
        $manifest = $access->fileGetContents($manifest_file);

        $manifest = explode(PHP_EOL, $manifest);
        $manifest = array_map('trim', $manifest);
        $manifest = array_filter($manifest, 'strlen');

        if (!empty($manifest)) {
            foreach ($manifest as $line) {
                $values = explode('    ', $line);
                $backupType = Backup::FULL_BACKUP;
                switch (count($values)) {
                    case 2:
                        $hash = '';
                        list($type, $path) = $values;
                        break;
                    case 3:
                        list($hash, $type, $path) = $values;
                        break;
                    case 4:
                        list($hash, $type, $path, $backupType) = $values;
                        break;
                }

                if ($type == 'conf_local' || $type == 'conf_external') {
                    $this->instance->system_config_file = $path;

                    $result = [
                        'location' => $type,
                        'file' => $hash,
                        'is_full' => $backupType == Backup::FULL_BACKUP,
                    ];

                    break;
                }
            }
        }

        return $result;
    }

    private function restoreFromVCS($src, $target)
    {
        $fileSystem = new Filesystem();
        $dest = implode(\DIRECTORY_SEPARATOR, [Environment::get('TEMP_FOLDER'),  md5(time()), $this->instance->name]);

        if ($fileSystem->exists($src . '/.svn')) {
            $className = Svn::class;
            $folder = '/.svn';
        } elseif ($fileSystem->exists($src . '/.git')) {
            $className = Git::class;
            $folder = '/.git';
        } else {
            return false;
        }

        $toCopy = $src . $folder;
        $dest .= $folder;
        $target .= $folder;
        $vcsInstance = new $className($this->instance);

        $fileSystem->mirror($toCopy, $dest);
        if ($this->restoreFolder($dest, $target, false)) {
            $vcsInstance->revert(dirname($target));
            return true;
        }
        return false;
    }

    public function applyChanges($changesFile)
    {
        $changes = file_exists($changesFile) ? file_get_contents($changesFile) : '';

        preg_match_all('/^D\s*(.*)/m', $changes, $matches);

        if (empty($matches[1])) {
            return;
        }

        foreach ($matches[1] as $file) {
            $this->access->deleteFile($file);
        }
    }

    public function setRestoreRoot($path): void
    {
        $this->restoreRoot = $path;
    }

    public function setRestoreDirname($dirName): void
    {
        $this->restoreDirname = $dirName;
    }

    protected function isSSHToLocal(): bool
    {
        $sourceInstance = $this->getSourceInstance();
        return $sourceInstance && $sourceInstance->type == 'ssh' && $this->instance->type == 'local';
    }

    protected function isLocalToSSH(): bool
    {
        $sourceInstance = $this->getSourceInstance();
        return $sourceInstance && $sourceInstance->type == 'local' && $this->instance->type == 'ssh';
    }

    public function removeRestoreFolder(): void
    {
        $this->removeFolder($this->getRestoreFolder());
    }

    public function removeRestoreRootFolder(): void
    {
        $this->removeFolder($this->getRestoreRoot());
    }

    private function removeFolder($path):void
    {
        $flags = '-Rf';
        if (ApplicationHelper::isWindows()) {
            $flags = "-r";
        }

        $this->access->shellExec(
            sprintf("rm %s %s", $flags, $path)
        );
    }
}