use Cli\Services\AppLocator;
use Cli\Services\EnvironmentLoader;
-use Cli\Services\ProgramRunner;
+use Cli\Services\MySqlRunner;
use RecursiveDirectoryIterator;
use SplFileInfo;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Process\Exception\ProcessTimedOutException;
use ZipArchive;
final class BackupCommand extends Command
$this->addOption('no-database', null, null, "Skip adding a database dump to the backup");
$this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup");
$this->addOption('no-themes', null, null, "Skip adding the themes folder to the backup");
+ $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
}
/**
protected function createDatabaseDump(string $appDir): string
{
$envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
- $dbOptions = [
- 'host' => ($envOptions['DB_HOST'] ?? ''),
- 'username' => ($envOptions['DB_USERNAME'] ?? ''),
- 'password' => ($envOptions['DB_PASSWORD'] ?? ''),
- 'database' => ($envOptions['DB_DATABASE'] ?? ''),
- ];
-
- $port = $envOptions['DB_PORT'] ?? '';
- if ($port) {
- $dbOptions['host'] .= ':' . $port;
- }
+ $mysql = MySqlRunner::fromEnvOptions($envOptions);
+ $mysql->ensureOptionsSet();
- foreach ($dbOptions as $name => $option) {
- if (!$option) {
- throw new CommandError("Could not find a value for the database {$name}");
- }
- }
-
- $errors = "";
- $hasOutput = false;
$dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
- $dumpTempFileResource = fopen($dumpTempFile, 'w');
-
try {
- (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
- ->withTimeout(240)
- ->withIdleTimeout(15)
- ->runWithoutOutputCallbacks([
- '-h', $dbOptions['host'],
- '-u', $dbOptions['username'],
- '-p' . $dbOptions['password'],
- '--single-transaction',
- '--no-tablespaces',
- $dbOptions['database'],
- ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
- fwrite($dumpTempFileResource, $data);
- $hasOutput = true;
- }, function ($error) use (&$errors) {
- $errors .= $error . "\n";
- });
+ $mysql->runDumpToFile($dumpTempFile);
} catch (\Exception $exception) {
- fclose($dumpTempFileResource);
unlink($dumpTempFile);
- if ($exception instanceof ProcessTimedOutException) {
- if (!$hasOutput) {
- throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
- } else {
- throw new CommandError("mysqldump operation timed-out after data was received.");
- }
- }
throw new CommandError($exception->getMessage());
}
- fclose($dumpTempFileResource);
-
- if ($errors) {
- unlink($dumpTempFile);
- throw new CommandError("Failed mysqldump with errors:\n" . $errors);
- }
-
return $dumpTempFile;
}
}
use Cli\Services\BackupZip;
use Cli\Services\EnvironmentLoader;
use Cli\Services\InteractiveConsole;
+use Cli\Services\MySqlRunner;
+use Cli\Services\ProgramRunner;
use Cli\Services\RequirementsValidator;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
$appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("<info>Checking system requirements...</info>");
RequirementsValidator::validate();
+ (new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
$zipPath = realpath($input->getArgument('backup-zip'));
$zip = new BackupZip($zipPath);
+ // TODO - Fix folders not being picked up here:
$contents = $zip->getContentsOverview();
$output->writeln("\n<info>Contents found in the backup ZIP:</info>");
$output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
$output->writeln("<info>Existing content may be overwritten.</info>");
- $output->writeln("<info>Do you want to continue?</info>");
if (!$interactions->confirm("Do you want to continue?")) {
$output->writeln("<info>Stopping restore operation.</info>");
}
$zip->extractInto($extractDir);
- // TODO - Cleanup temp extract dir
-
- // TODO - Environment handling
- // - Restore of old .env
- // - Prompt for correct DB details (Test before serving?)
- // - Prompt for correct URL (Allow entry of new?)
+ if ($contents['env']['exists']) {
+ $output->writeln("<info>Restoring and merging .env file...</info>");
+ $this->restoreEnv($extractDir, $appDir);
+ }
- // TODO - Restore folders from backup
+ $folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
+ foreach ($folderLocations as $folderSubPath) {
+ if ($contents[$folderSubPath]['exists']) {
+ $output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
+ $this->restoreFolder($folderSubPath, $appDir, $extractDir);
+ }
+ }
- // TODO - Restore database from backup
+ if ($contents['db']['exists']) {
+ $output->writeln("<info>Restoring database from SQL dump...</info>");
+ $this->restoreDatabase($appDir, $extractDir);
- $output->writeln("<info>Running database migrations...</info>");
- $artisan = (new ArtisanRunner($appDir));
- $artisan->run(['migrate', '--force']);
+ $output->writeln("<info>Running database migrations...</info>");
+ $artisan = (new ArtisanRunner($appDir));
+ $artisan->run(['migrate', '--force']);
+ }
+ // TODO - Handle change of URL?
// TODO - Update system URL (via BookStack artisan command) if
// there's been a change from old backup env
$artisan->run(['config:clear']);
$artisan->run(['view:clear']);
+ $output->writeln("<info>Cleaning up extract directory...</info>");
+ $this->deleteDirectoryAndContents($extractDir);
+
+ $output->writeln("<info>\nRestore operation complete!</info>");
+
return Command::SUCCESS;
}
- protected function restoreEnv(string $extractDir, string $appDir, InteractiveConsole $interactions)
+ protected function restoreEnv(string $extractDir, string $appDir)
{
- $extractEnv = EnvironmentLoader::load($extractDir);
- $appEnv = EnvironmentLoader::load($appDir); // TODO - Probably pass in since we'll need the APP_URL later on.
+ $oldEnv = EnvironmentLoader::load($extractDir);
+ $currentEnv = EnvironmentLoader::load($appDir);
+ $envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env');
+ $appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env';
+
+ $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
+ $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
+ if (!$mysqlOld->testConnection()) {
+ $currentWorking = $mysqlCurrent->testConnection();
+ if (!$currentWorking) {
+ throw new CommandError("Could not find a working database configuration");
+ }
- // TODO - Create mysql runner to take variables to a programrunner instance.
- // Then test each, backup existing env, then replace env with old then overwrite
- // db options if the new app env options are the valid ones.
+ // Copy across new env details to old env
+ $currentEnvContents = file_get_contents($appEnvPath);
+ $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
+ return str_starts_with($line, 'DB_');
+ }));
+ $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
+ return !str_starts_with($line, 'DB_');
+ }));
+ $envContents = implode("\n", [
+ '# Database credentials merged from existing .env file',
+ ...$currentEnvDbLines,
+ ...$oldEnvLines
+ ]);
+ copy($appEnvPath, $appEnvPath . '.backup');
+ }
+
+ file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
+ }
+
+ protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
+ {
+ $fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath;
+ $this->deleteDirectoryAndContents($fullAppFolderPath);
+ rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath);
+ }
+
+ protected function deleteDirectoryAndContents(string $dir)
+ {
+ $files = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($files as $fileinfo) {
+ $path = $fileinfo->getRealPath();
+ $fileinfo->isDir() ? rmdir($path) : unlink($path);
+ }
+
+ rmdir($dir);
+ }
+
+ protected function restoreDatabase(string $appDir, string $extractDir): void
+ {
+ $dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql';
+ $currentEnv = EnvironmentLoader::load($appDir);
+ $mysql = MySqlRunner::fromEnvOptions($currentEnv);
+ $mysql->importSqlFile($dbDump);
}
}
}
}
+ /**
+ * @return array<string, array{desc: string, exists: bool}>
+ */
public function getContentsOverview(): array
{
return [
],
'themes' => [
'desc' => 'Themes Folder',
- 'exists' => boolval($this->zip->statName('themes')),
+ 'exists' => $this->zip->locateName('/themes/') !== false,
],
- 'public-uploads' => [
+ 'public/uploads' => [
'desc' => 'Public File Uploads',
- 'exists' => boolval($this->zip->statName('public/uploads')),
+ 'exists' => $this->zip->locateName('/public/uploads/') !== false,
],
- 'storage-uploads' => [
+ 'storage/uploads' => [
'desc' => 'Private File Uploads',
- 'exists' => boolval($this->zip->statName('storage/uploads')),
+ 'exists' => $this->zip->locateName('/storage/uploads/') !== false,
],
'db' => [
'desc' => 'Database Dump',
namespace Cli\Services;
-use Illuminate\Console\QuestionHelper;
+use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
public function confirm(string $text): bool
{
- $question = new ConfirmationQuestion($text, false);
+ $question = new ConfirmationQuestion($text . " (y/n)\n", false);
return $this->helper->ask($this->input, $this->output, $question);
}
}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace Cli\Services;
+
+use Exception;
+
+class MySqlRunner
+{
+ public function __construct(
+ protected string $host,
+ protected string $user,
+ protected string $password,
+ protected string $database,
+ protected int $port = 3306
+ ) {
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function ensureOptionsSet(): void
+ {
+ $options = ['host', 'user', 'password', 'database'];
+ foreach ($options as $option) {
+ if (!$this->$option) {
+ throw new Exception("Could not find a valid value for the \"{$option}\" database option.");
+ }
+ }
+ }
+
+ public function testConnection(): bool
+ {
+ $output = (new ProgramRunner('mysql', '/usr/bin/mysql'))
+ ->withTimeout(240)
+ ->withIdleTimeout(5)
+ ->runCapturingStdErr([
+ '-h', $this->host,
+ '-P', $this->port,
+ '-u', $this->user,
+ '-p' . $this->password,
+ $this->database,
+ '-e' . "'show tables;'"
+ ]);
+
+ return !$output;
+ }
+
+ public function importSqlFile(string $sqlFilePath): void
+ {
+ $output = (new ProgramRunner('mysql', '/usr/bin/mysql'))
+ ->withTimeout(240)
+ ->withIdleTimeout(5)
+ ->runCapturingStdErr([
+ '-h', $this->host,
+ '-P', $this->port,
+ '-u', $this->user,
+ '-p' . $this->password,
+ $this->database,
+ '<', $sqlFilePath
+ ]);
+
+ if ($output) {
+ throw new Exception("Failed mysql file import with errors:\n" . $output);
+ }
+ }
+
+ public function runDumpToFile(string $filePath): void
+ {
+ $file = fopen($filePath, 'w');
+ $errors = "";
+ $hasOutput = false;
+
+ try {
+ (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
+ ->withTimeout(240)
+ ->withIdleTimeout(15)
+ ->runWithoutOutputCallbacks([
+ '-h', $this->host,
+ '-P', $this->port,
+ '-u', $this->user,
+ '-p' . $this->password,
+ '--single-transaction',
+ '--no-tablespaces',
+ $this->database,
+ ], function ($data) use (&$file, &$hasOutput) {
+ fwrite($file, $data);
+ $hasOutput = true;
+ }, function ($error) use (&$errors) {
+ $errors .= $error . "\n";
+ });
+ } catch (\Exception $exception) {
+ fclose($file);
+ if ($exception instanceof ProcessTimedOutException) {
+ if (!$hasOutput) {
+ throw new Exception("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
+ } else {
+ throw new Exception("mysqldump operation timed-out after data was received.");
+ }
+ }
+ throw new Exception($exception->getMessage());
+ }
+
+ fclose($file);
+
+ if ($errors) {
+ throw new Exception("Failed mysqldump with errors:\n" . $errors);
+ }
+ }
+
+ public static function fromEnvOptions(array $env): static
+ {
+ $host = ($env['DB_HOST'] ?? '');
+ $username = ($env['DB_USERNAME'] ?? '');
+ $password = ($env['DB_PASSWORD'] ?? '');
+ $database = ($env['DB_DATABASE'] ?? '');
+ $port = intval($env['DB_PORT'] ?? 3306);
+
+ return new static($host, $username, $password, $database, $port);
+ }
+}
\ No newline at end of file