2023-03-03 03:44:08 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
namespace Cli\Commands;
|
|
|
|
|
2023-03-06 18:35:23 +01:00
|
|
|
use Cli\Services\AppLocator;
|
2023-03-04 16:26:27 +01:00
|
|
|
use Cli\Services\EnvironmentLoader;
|
2023-03-07 19:10:44 +01:00
|
|
|
use Cli\Services\MySqlRunner;
|
2023-03-03 03:44:08 +01:00
|
|
|
use RecursiveDirectoryIterator;
|
2023-03-03 18:22:24 +01:00
|
|
|
use SplFileInfo;
|
2023-03-05 16:28:02 +01:00
|
|
|
use Symfony\Component\Console\Command\Command;
|
|
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
|
|
use Symfony\Component\Console\Input\InputInterface;
|
2023-03-07 19:10:44 +01:00
|
|
|
use Symfony\Component\Console\Input\InputOption;
|
2023-03-05 16:28:02 +01:00
|
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
2023-03-03 03:44:08 +01:00
|
|
|
use ZipArchive;
|
|
|
|
|
2023-03-05 16:28:02 +01:00
|
|
|
final class BackupCommand extends Command
|
2023-03-03 03:44:08 +01:00
|
|
|
{
|
2023-03-05 16:28:02 +01:00
|
|
|
protected function configure(): void
|
|
|
|
{
|
|
|
|
$this->setName('backup');
|
|
|
|
$this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
|
|
|
|
$this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
|
|
|
|
$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");
|
2023-03-07 19:10:44 +01:00
|
|
|
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
|
2023-03-03 03:44:08 +01:00
|
|
|
}
|
|
|
|
|
2023-03-03 22:01:30 +01:00
|
|
|
/**
|
|
|
|
* @throws CommandError
|
|
|
|
*/
|
2023-03-05 16:28:02 +01:00
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
2023-03-03 03:44:08 +01:00
|
|
|
{
|
2023-03-06 18:35:23 +01:00
|
|
|
$appDir = AppLocator::require($input->getOption('app-directory'));
|
|
|
|
$output->writeln("<info>Checking system requirements...</info>");
|
2023-03-03 22:01:30 +01:00
|
|
|
$this->ensureRequiredExtensionInstalled();
|
|
|
|
|
2023-03-05 16:28:02 +01:00
|
|
|
$handleDatabase = !$input->getOption('no-database');
|
|
|
|
$handleUploads = !$input->getOption('no-uploads');
|
|
|
|
$handleThemes = !$input->getOption('no-themes');
|
|
|
|
$suggestedOutPath = $input->getArgument('backup-path');
|
2023-03-03 03:44:08 +01:00
|
|
|
|
2023-03-06 18:35:23 +01:00
|
|
|
$zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
|
2023-03-03 18:22:24 +01:00
|
|
|
|
|
|
|
// Create a new ZIP file
|
|
|
|
$zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
|
2023-03-04 16:06:38 +01:00
|
|
|
$dumpTempFile = '';
|
2023-03-03 18:22:24 +01:00
|
|
|
$zip = new ZipArchive();
|
|
|
|
$zip->open($zipTempFile, ZipArchive::CREATE);
|
2023-03-03 22:01:30 +01:00
|
|
|
|
|
|
|
// Add default files (.env config file and this CLI)
|
2023-03-06 18:35:23 +01:00
|
|
|
$zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
|
|
|
|
$zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
|
2023-03-03 18:22:24 +01:00
|
|
|
|
|
|
|
if ($handleDatabase) {
|
2023-03-05 16:28:02 +01:00
|
|
|
$output->writeln("<info>Dumping the database via mysqldump...</info>");
|
2023-03-06 18:35:23 +01:00
|
|
|
$dumpTempFile = $this->createDatabaseDump($appDir);
|
2023-03-05 16:28:02 +01:00
|
|
|
$output->writeln("<info>Adding database dump to backup archive...</info>");
|
2023-03-03 18:22:24 +01:00
|
|
|
$zip->addFile($dumpTempFile, 'db.sql');
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($handleUploads) {
|
2023-03-05 16:28:02 +01:00
|
|
|
$output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
|
2023-03-06 18:35:23 +01:00
|
|
|
$this->addUploadFoldersToZip($zip, $appDir);
|
2023-03-03 18:22:24 +01:00
|
|
|
}
|
|
|
|
|
2023-03-03 22:01:30 +01:00
|
|
|
if ($handleThemes) {
|
2023-03-05 16:28:02 +01:00
|
|
|
$output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
|
2023-03-06 18:35:23 +01:00
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
|
2023-03-03 22:01:30 +01:00
|
|
|
}
|
|
|
|
|
2023-03-03 18:22:24 +01:00
|
|
|
// Close off our zip and move it to the required location
|
|
|
|
$zip->close();
|
2023-03-04 16:06:38 +01:00
|
|
|
// Delete our temporary DB dump file if exists. Must be done after zip close.
|
|
|
|
if ($dumpTempFile) {
|
|
|
|
unlink($dumpTempFile);
|
|
|
|
}
|
|
|
|
// Move the zip into the target location
|
2023-03-03 18:22:24 +01:00
|
|
|
rename($zipTempFile, $zipOutFile);
|
|
|
|
|
2023-03-04 03:40:29 +01:00
|
|
|
// Announce end
|
2023-03-05 16:28:02 +01:00
|
|
|
$output->writeln("<info>Backup finished.</info>");
|
|
|
|
$output->writeln("Output ZIP saved to: {$zipOutFile}");
|
|
|
|
|
|
|
|
return Command::SUCCESS;
|
2023-03-03 22:01:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Ensure the required PHP extensions are installed for this command.
|
|
|
|
* @throws CommandError
|
|
|
|
*/
|
|
|
|
protected function ensureRequiredExtensionInstalled(): void
|
|
|
|
{
|
|
|
|
if (!extension_loaded('zip')) {
|
|
|
|
throw new CommandError('The "zip" PHP extension is required to run this command');
|
|
|
|
}
|
2023-03-03 18:22:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Build a full zip path from the given suggestion, which may be empty,
|
|
|
|
* a path to a folder, or a path to a file in relative or absolute form.
|
2023-03-03 22:01:30 +01:00
|
|
|
* @throws CommandError
|
2023-03-03 18:22:24 +01:00
|
|
|
*/
|
2023-03-06 18:35:23 +01:00
|
|
|
protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
|
2023-03-03 18:22:24 +01:00
|
|
|
{
|
2023-03-06 18:35:23 +01:00
|
|
|
$zipDir = getcwd() ?: $appDir;
|
2023-03-03 18:22:24 +01:00
|
|
|
$zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
|
|
|
|
|
|
|
|
if ($suggestedOutPath) {
|
|
|
|
if (is_dir($suggestedOutPath)) {
|
|
|
|
$zipDir = realpath($suggestedOutPath);
|
|
|
|
} else if (is_dir(dirname($suggestedOutPath))) {
|
|
|
|
$zipDir = realpath(dirname($suggestedOutPath));
|
|
|
|
$zipName = basename($suggestedOutPath);
|
|
|
|
} else {
|
2023-03-03 22:01:30 +01:00
|
|
|
throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
|
2023-03-03 18:22:24 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
|
2023-03-03 03:44:08 +01:00
|
|
|
|
2023-03-03 18:22:24 +01:00
|
|
|
if (file_exists($fullPath)) {
|
2023-03-03 22:01:30 +01:00
|
|
|
throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
|
2023-03-03 18:22:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return $fullPath;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add app-relative upload folders to the provided zip archive.
|
|
|
|
* Will recursively go through all directories to add all files.
|
|
|
|
*/
|
2023-03-06 18:35:23 +01:00
|
|
|
protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
|
2023-03-03 18:22:24 +01:00
|
|
|
{
|
2023-03-06 18:35:23 +01:00
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
|
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
|
2023-03-03 22:01:30 +01:00
|
|
|
}
|
2023-03-03 18:22:24 +01:00
|
|
|
|
2023-03-03 22:01:30 +01:00
|
|
|
/**
|
|
|
|
* Recursively add all contents of the given dirPath to the provided zip file
|
|
|
|
* with a zip location of the targetZipPath.
|
|
|
|
*/
|
|
|
|
protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
|
|
|
|
{
|
|
|
|
$dirIter = new RecursiveDirectoryIterator($dirPath);
|
|
|
|
$fileIter = new \RecursiveIteratorIterator($dirIter);
|
|
|
|
/** @var SplFileInfo $file */
|
|
|
|
foreach ($fileIter as $file) {
|
|
|
|
if (!$file->isDir()) {
|
|
|
|
$zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
|
2023-03-03 18:22:24 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a database dump and return the path to the dumped SQL output.
|
2023-03-03 22:01:30 +01:00
|
|
|
* @throws CommandError
|
2023-03-03 18:22:24 +01:00
|
|
|
*/
|
2023-03-06 18:35:23 +01:00
|
|
|
protected function createDatabaseDump(string $appDir): string
|
2023-03-03 18:22:24 +01:00
|
|
|
{
|
2023-03-06 18:35:23 +01:00
|
|
|
$envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
|
2023-03-07 19:10:44 +01:00
|
|
|
$mysql = MySqlRunner::fromEnvOptions($envOptions);
|
|
|
|
$mysql->ensureOptionsSet();
|
2023-03-03 22:16:15 +01:00
|
|
|
|
2023-03-04 16:06:38 +01:00
|
|
|
$dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
|
2023-03-03 22:01:30 +01:00
|
|
|
try {
|
2023-03-07 19:10:44 +01:00
|
|
|
$mysql->runDumpToFile($dumpTempFile);
|
2023-03-04 16:06:38 +01:00
|
|
|
} catch (\Exception $exception) {
|
2023-03-03 22:01:30 +01:00
|
|
|
unlink($dumpTempFile);
|
2023-03-04 16:06:38 +01:00
|
|
|
throw new CommandError($exception->getMessage());
|
2023-03-03 03:44:08 +01:00
|
|
|
}
|
2023-03-03 22:01:30 +01:00
|
|
|
|
2023-03-03 18:22:24 +01:00
|
|
|
return $dumpTempFile;
|
2023-03-03 03:44:08 +01:00
|
|
|
}
|
|
|
|
}
|