TikiWiki/tiki-manager

View on GitHub
src/Application/Backup.php

Summary

Maintainability
D
2 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\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use TikiManager\Application\Exception\FolderPermissionException;
use TikiManager\Config\App;
use TikiManager\Access\Access;
use TikiManager\Libs\VersionControl\Svn;
use TikiManager\Libs\Helpers\ApplicationHelper;
use TikiManager\Libs\VersionControl\VersionControlSystem;
use TikiManager\Application\Exception\BackupCopyException;
use TikiManager\Config\Environment;

class Backup
{
    public const FULL_BACKUP = 'full';
    public const PARTIAL_BACKUP = 'partial';

    /** @var SymfonyStyle */
    protected $io;

    /** @var Access $access */
    protected $access;
    protected $app;
    protected $archiveDir;
    protected $archiveRoot;
    protected $backupDir;
    protected $backupDirname;
    protected $backupRoot;
    protected $errors;
    protected $instance;
    protected $workpath;
    protected $direct;
    protected $onlyCode;
    protected $full;

    /**
     * Backup constructor.
     * @param $instance
     * @param bool $direct
     * @param bool $full
     * @param bool $onlyCode
     * @throws FolderPermissionException
     */
    public function __construct($instance, $direct = false, $full = true, $onlyCode = false)
    {
        $this->setIO(App::get('io'));

        $this->instance = $instance;
        $this->access = $this->getAccess($instance);
        $this->app = $instance->getApplication();
        $this->workpath = $instance->createWorkPath($this->access);
        $this->archiveRoot = rtrim($_ENV['ARCHIVE_FOLDER'], DIRECTORY_SEPARATOR);
        $this->backupRoot = rtrim($_ENV['BACKUP_FOLDER'], DIRECTORY_SEPARATOR);
        $this->backupDirname = sprintf('%s-%s', $instance->id, $instance->name);
        $this->backupDir = $this->backupRoot . DIRECTORY_SEPARATOR . $this->backupDirname;
        $this->archiveDir = $this->archiveRoot . DIRECTORY_SEPARATOR . $this->backupDirname;
        $this->direct = $direct;
        $this->onlyCode = $onlyCode;
        $this->errors = [];
        $this->full = !in_array($instance->vcs_type, ['git', 'svn']) ? true : $full;

        $this->createBackupDir();
        $this->createArchiveDir();
    }

    public function copyDirectories($targets, $backupDir)
    {
        $access = $this->getAccess();
        $backupDir = $backupDir ?: $this->backupDir;
        $result = [];

        $fileSystem = new Filesystem();

        foreach ($targets as $target) {
            if ($this->direct) {
                return $access->localizeFolder($target, $backupDir);
            } else {
                list($type, $dir) = $target;
                $hash = md5($dir);
                $destDir = $backupDir . DIRECTORY_SEPARATOR . $hash;
                if ($type == 'app' && !$this->full) {
                    $files = $this->app->getFilesToBackup();
                    $dirTmp = $this->createTempPartial($dir, $files);
                    $error_code = $access->localizeFolder($dirTmp, $destDir);
                    $fileSystem->remove([dirname($dirTmp)]);
                } else {
                    $error_code = $access->localizeFolder($dir, $destDir);
                }

                if ($error_code) {
                    if (array_key_exists($error_code, $this->errors)) {
                        $this->errors[$error_code][] = $dir;
                    } else {
                        $this->errors[$error_code] = [$error_code => $dir];
                    }
                } else {
                    if ($this->instance->vcs_type == 'svn') {
                        /** @var Svn $svn */
                        $svn = VersionControlSystem::getVersionControlSystem($this->instance);
                        $svn->ensureTempFolder($destDir . DIRECTORY_SEPARATOR . basename($dir));
                    }
                }

                $result[] = [
                    $hash,
                    $type,
                    $dir,
                    $this->full ? Backup::FULL_BACKUP : Backup::PARTIAL_BACKUP
                ];
            }
        }

        if (!empty($this->errors)) {
            throw new BackupCopyException(
                $this->errors,
                BackupCopyException::RSYNC_ERROR
            );
        }

        return $result;
    }

    public function create($skipArchive = false, $backupDir = null)
    {
        $backupDir = $backupDir ?: $this->backupDir;

        $this->io->writeln('Checking directories...');
        $targets = $this->getTargetDirectories();

        $copyResult = $targets;

        if (!$this->direct) {
            $this->io->writeln('Copying files... <fg=yellow>[may take a while]</>');
            $copyResult = $this->copyDirectories($targets, $backupDir);

            $this->io->writeln('Creating changes file...');
            $this->createChangesFile($backupDir);
        }

        $this->io->writeln('Checking system ini config file...');
        $targetSystemIniConfigFile = $this->getSystemIniConfigFile();
        if (!empty($targetSystemIniConfigFile)) {
            $parts = explode('||', $targetSystemIniConfigFile);
            if (isset($parts[0]) && isset($parts[1])) {
                $targetSystemIniConfigFile = $parts[0];

                if ($parts[1] == 'external') {
                    $this->io->writeln('Downloading system ini config file...');
                }

                $this->copySystemIniConfigFile($targetSystemIniConfigFile, $backupDir, $copyResult, $parts[1]);
            }
        }

        $this->io->writeln('Creating manifest...');
        $this->createManifest($copyResult, $backupDir);

        if (! $this->onlyCode) {
            $this->io->writeln('Creating database dump...');
            $this->createDatabaseDump($this->app, $backupDir);
        }

        $result = $backupDir;
        if (!$skipArchive || !$this->direct) {
            $this->io->writeln('Creating archive... <fg=yellow>[may take a while]</>');
            $result = $this->createArchive();
        }

        return !$result ? false : $result;
    }

    public function createArchive($archiveDir = null)
    {
        $archiveDir = $archiveDir ?: $this->archiveDir;

        $nice = 'nice -n 19';
        $bzipStep = false;

        // If its windows we need to tar first and then bzip2 the tar
        if (ApplicationHelper::isWindows()) {
            $bzipStep = true;
            $nice = '';
        }

        $fileName = sprintf('%s_%s.tar%s', $this->backupDirname, date('Y-m-d_H-i-s'), $bzipStep ? '' : '.bz2');
        $tarPath = $archiveDir . DIRECTORY_SEPARATOR . $fileName;

        $command = sprintf(
            "%s tar -cp%s -C %s -f %s %s",
            $nice,
            $bzipStep ? '' : 'j',
            escapeshellarg($this->backupRoot),
            escapeshellarg($tarPath),
            escapeshellarg($this->backupDirname)
        );

        exec($command, $output, $return_var);

        if ($return_var != 0) {
            $this->io->error("TAR exit code: $return_var");
        }

        if (!$bzipStep) {
            $success = $return_var === 0
                && file_exists($tarPath)
                && filesize($tarPath) > 0;

            return $success ? $tarPath : false;
        }

        $command = sprintf('bzip2 %s', $tarPath);
        exec($command, $output, $return_var);

        $tarPath .= '.bz2';
        $success = $return_var === 0
            && file_exists($tarPath)
            && filesize($tarPath) > 0;

        return $success ? $tarPath : false;
    }

    /**
     * @param null $archiveDir
     * @return bool|string|null
     * @throws FolderPermissionException
     */
    public function createArchiveDir($archiveDir = null)
    {
        $archiveDir = $archiveDir ?: $this->archiveDir;

        return $this->createDir($archiveDir);
    }

    /**
     * @param null $backupDir
     * @return string|null
     * @throws FolderPermissionException
     */
    public function createBackupDir($backupDir = null)
    {
        $backupDir = $backupDir ?: $this->backupDir;

        return $this->createDir($backupDir);
    }

    protected function createDir($folder)
    {
        $parentFolder = dirname($folder);

        $exceptionMessage = 'Folder "%s" is not writable. Tiki-manager requires write privileges in order to create backups.';

        if (!is_writable($parentFolder) && !$this->fixPermissions($parentFolder)) {
            throw new FolderPermissionException(sprintf($exceptionMessage, $parentFolder));
        }

        if (is_dir($folder) || mkdir($folder, $this->getFilePerm(), true)) {
            if (!$this->fixPermissions($folder)) {
                throw new FolderPermissionException(sprintf($exceptionMessage, $folder));
            }

            return $folder;
        }
        return false;
    }

    public function createDatabaseDump($app, $backupDir = null)
    {
        $app = $app ?: $this->app;
        $backupDir = $backupDir ?: $this->backupDir;
        $sqlpath = $backupDir . DIRECTORY_SEPARATOR . 'database_dump.sql';

        file_exists($sqlpath) && unlink($sqlpath);

        if (!$app->backupDatabase($sqlpath)) {
            throw new \RuntimeException('Unsuccessful database backup. Aborting.');
        }

        if (file_exists($sqlpath)) {
            $this->fixPermissions($sqlpath);
            return $sqlpath;
        }

        return false;
    }

    public function createManifest($data, $backupDir = null)
    {
        $backupDir = $backupDir ?: $this->backupDir;
        $manifestFile = $backupDir . DIRECTORY_SEPARATOR . 'manifest.txt';
        $file = fopen($manifestFile, 'w');
        $lineTemplate = !$this->direct ? '%s    %s    %s    %s' : '%s    %s';

        foreach ($data as $location) {
            if ($line = @vsprintf($lineTemplate . PHP_EOL, $location)) {
                fwrite($file, $line);
            } else {
                $this->io->warning("Failed to generate manifest entry line");
                $error = "Invalid/insufficient data to generate the manifest:\n" .
                    "Received " . count($data) . " arguments instead of " . (!$this->direct ? 4 : 2) . "\n" .
                    var_export($data, true);
                debug($error, null, "\n\n");
            }
        }

        fclose($file);
        $this->fixPermissions($manifestFile);
        return $manifestFile;
    }

    public function createChangesFile($backupDir = null)
    {
        list('changed' => $changes, 'untracked' => $untracked, 'deleted' => $deleted) = $this->app->getFileChanges();

        $backupDir = $backupDir ?: $this->backupDir;

        $changesFile = $backupDir . '/changes.txt';
        $fileStream = fopen($changesFile, 'w');
        $lineTemplate = '%s    %s';

        foreach ($changes as $file) {
            $line = sprintf($lineTemplate . PHP_EOL, 'M', $file);
            fwrite($fileStream, $line);
        }

        foreach ($untracked as $file) {
            $line = sprintf($lineTemplate . PHP_EOL, 'A', $file);
            fwrite($fileStream, $line);
        }

        foreach ($deleted as $file) {
            $line = sprintf($lineTemplate . PHP_EOL, 'D', $file);
            fwrite($fileStream, $line);
        }

        fclose($fileStream);
        $this->fixPermissions($changesFile);
        return $changesFile;
    }

    /**
     * Fix folder permissions based on current backup properties
     * @param $path
     * @return bool
     */
    public function fixPermissions($path)
    {
        $filesystem = new Filesystem();
        $perm = $this->getFilePerm();
        $user = $this->getFileUser();
        $group = $this->getFileGroup();
        $errors = [];

        if (is_dir($path)) {       // avoid rw-rw-rw- for dirs
            $perm = (($perm & 0b100100100) >> 2) | $perm;
        } elseif (is_file($path)) { // avoid --x--x--x for files
            $perm = ($perm & 0b001001001) ^ $perm;
        }

        if ($perm) {
            try {
                $filesystem->chmod($path, $perm);
            } catch (IOException $e) {
                $errors[] = $e->getMessage();
            }
        }

        if (!is_null($group)) {
            try {
                $filesystem->chgrp($path, $group);
            } catch (IOException $e) {
                $errors[] = $e->getMessage();
            }
        }

        if (!is_null($user)) {
            try {
                $filesystem->chown($path, $user);
            } catch (IOException $e) {
                $errors[] = $e->getMessage();
            }
        }

        if ($errors) {
            $this->io->error(implode(PHP_EOL, $errors));
            return false;
        }

        return true;
    }

    public function getAccess($instance = null): Access
    {
        $instance = $instance ?: $this->instance;

        return $instance->getBestAccess();
    }

    public function getArchives($archiveRoot = null, $instance = null)
    {
        $archiveRoot = $archiveRoot ?: $this->archiveRoot;
        $instance = $instance ?: $this->instance;

        $globPattern = implode(DIRECTORY_SEPARATOR, [
            $archiveRoot,
            $instance->id . '-*',
            $instance->id . '*_*.tar.bz2',
        ]);
        return array_reverse(glob($globPattern));
    }

    public function getBackupDir()
    {
        return $this->backupDir;
    }

    public function getTargetDirectories()
    {
        $targets = [];
        $extraBackups = $this->instance->getExtraBackups() ?: [];
        $locations = $this->app->getFileLocations();

        foreach ($locations as $type => $directories) {
            foreach ($directories as $dir) {
                $targets[] = [$type, $dir];
            }
        }

        foreach ($extraBackups as $dir) {
            $targets[] = ['data', $dir];
        }

        return $targets;
    }

    /**
     * Get system ini config file path
     *
     * @return mixed
     */
    public function getSystemIniConfigFile()
    {
        return $this->app->getSystemIniConfigFilePath();
    }

    /**
     * Copy system ini config file to backup folder
     *
     * @param $path
     * @param $backupDir
     * @param $copyResult
     * @param $location
     */
    public function copySystemIniConfigFile($path, $backupDir, &$copyResult, $location)
    {
        $backupDir = $backupDir ?: $this->backupDir;

        $file = basename($path);

        if ($location == 'external') {
            $filePath = $path;
            if ($path[0] !== DIRECTORY_SEPARATOR) {
                $webroot = rtrim($this->instance->webroot, DIRECTORY_SEPARATOR);
                $filePath = $webroot . DIRECTORY_SEPARATOR . $path;
                $filePath = ApplicationHelper::getAbsolutePath($filePath);
            }

            $access = $this->getAccess();
            $access->downloadFile($filePath, $backupDir);
        }

        $copyResult[] = $this->direct ? ['conf_' . $location, $path] : [$file, 'conf_' . $location, $path, null];
    }

    public function setArchiveSymlink($symlinkPath = null, $archiveDir = null, $instance = null)
    {
        if (file_exists($symlinkPath)) { // if destination path exists, skip
            return true;
        }

        $archiveDir = $archiveDir ?: $this->archiveDir;
        $instance = $instance ?: $this->instance;
        $symlinkPath = $symlinkPath ?: dirname($instance->webroot) . DIRECTORY_SEPARATOR . 'backup';

        // If Tiki Manager archive dir is a link, swap link and target
        if (is_link($archiveDir)) {
            $realArchiveDir = readlink($archiveDir);
            unlink($archiveDir);
            if (file_exists($realArchiveDir)) {
                rename($realArchiveDir, $archiveDir);
            } else {
                mkdir($archiveDir, $this->getFilePerm(), true);
            }
        }

        symlink($archiveDir, $symlinkPath);
        $success = is_dir($archiveDir)
            && is_link($symlinkPath)
            && readlink($symlinkPath) === $archiveDir;

        $this->fixPermissions($archiveDir);
        return $success;
    }

    private function createTempPartial($root, $files)
    {
        $fileSystem = new Filesystem();
        $temp = implode(
            \DIRECTORY_SEPARATOR,
            [Environment::get('TEMP_FOLDER'), md5(time()), basename($this->instance->webroot)]
        );
        foreach ($files as $file) {
            $dest = $temp . DIRECTORY_SEPARATOR . $file;
            $src = $root . DIRECTORY_SEPARATOR . $file;
            if (!$fileSystem->exists($src)) {
                continue;
            }
            $fileSystem->mkdir(dirname($dest));
            if (is_dir($src)) {
                $fileSystem->mirror($src, $dest);
            } else {
                $fileSystem->copy($src, $dest, true);
            }
        }

        return $temp;
    }

    public function getFilePerm()
    {
        return intval($this->instance->getProp('backup_perm')) ?: 0770;
    }

    public function getFileUser()
    {
        return $this->instance->getProp('backup_user');
    }

    public function getFileGroup()
    {
        return $this->instance->getProp('backup_group');
    }

    public function setIO(SymfonyStyle $io)
    {
        $this->io = $io;
    }
}