namespace Cli\Commands;
-use Minicli\Command\CommandCall;
+use Cli\Services\AppLocator;
+use Cli\Services\EnvironmentLoader;
+use Cli\Services\ProgramRunner;
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\Output\OutputInterface;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
-use Symfony\Component\Process\ExecutableFinder;
-use Symfony\Component\Process\Process;
use ZipArchive;
-final class BackupCommand
+final class BackupCommand extends Command
{
- public function __construct(
- protected string $appDir
- ) {
+ 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");
}
/**
* @throws CommandError
*/
- public function handle(CommandCall $input)
+ protected function execute(InputInterface $input, OutputInterface $output): int
{
+ $appDir = AppLocator::require($input->getOption('app-directory'));
+ $output->writeln("<info>Checking system requirements...</info>");
$this->ensureRequiredExtensionInstalled();
- $handleDatabase = !$input->hasFlag('no-database');
- $handleUploads = !$input->hasFlag('no-uploads');
- $handleThemes = !$input->hasFlag('no-themes');
- $suggestedOutPath = $input->subcommand;
- if ($suggestedOutPath === 'default') {
- $suggestedOutPath = '';
- }
+ $handleDatabase = !$input->getOption('no-database');
+ $handleUploads = !$input->getOption('no-uploads');
+ $handleThemes = !$input->getOption('no-themes');
+ $suggestedOutPath = $input->getArgument('backup-path');
- $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
+ $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
// Create a new ZIP file
$zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
+ $dumpTempFile = '';
$zip = new ZipArchive();
$zip->open($zipTempFile, ZipArchive::CREATE);
// Add default files (.env config file and this CLI)
- $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
- $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
+ $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
+ $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
if ($handleDatabase) {
- echo "Dumping the database via mysqldump...\n";
- $dumpTempFile = $this->createDatabaseDump();
- echo "Adding database dump to backup archive...\n";
+ $output->writeln("<info>Dumping the database via mysqldump...</info>");
+ $dumpTempFile = $this->createDatabaseDump($appDir);
+ $output->writeln("<info>Adding database dump to backup archive...</info>");
$zip->addFile($dumpTempFile, 'db.sql');
- // Delete our temporary DB dump file
- unlink($dumpTempFile);
}
if ($handleUploads) {
- echo "Adding BookStack upload folders to backup archive...\n";
- $this->addUploadFoldersToZip($zip);
+ $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
+ $this->addUploadFoldersToZip($zip, $appDir);
}
if ($handleThemes) {
- echo "Adding BookStack theme folders to backup archive...\n";
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
+ $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
}
// Close off our zip and move it to the required location
$zip->close();
+ // 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
rename($zipTempFile, $zipOutFile);
// Announce end
- echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
+ $output->writeln("<info>Backup finished.</info>");
+ $output->writeln("Output ZIP saved to: {$zipOutFile}");
+
+ return Command::SUCCESS;
}
/**
* a path to a folder, or a path to a file in relative or absolute form.
* @throws CommandError
*/
- protected function buildZipFilePath(string $suggestedOutPath): string
+ protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
{
- $zipDir = getcwd() ?: $this->appDir;
+ $zipDir = getcwd() ?: $appDir;
$zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
if ($suggestedOutPath) {
* Add app-relative upload folders to the provided zip archive.
* Will recursively go through all directories to add all files.
*/
- protected function addUploadFoldersToZip(ZipArchive $zip): void
+ protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
{
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
}
/**
* Create a database dump and return the path to the dumped SQL output.
* @throws CommandError
*/
- protected function createDatabaseDump(): string
+ protected function createDatabaseDump(string $appDir): string
{
+ $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
$dbOptions = [
- 'host' => ($_SERVER['DB_HOST'] ?? ''),
- 'username' => ($_SERVER['DB_USERNAME'] ?? ''),
- 'password' => ($_SERVER['DB_PASSWORD'] ?? ''),
- 'database' => ($_SERVER['DB_DATABASE'] ?? ''),
+ 'host' => ($envOptions['DB_HOST'] ?? ''),
+ 'username' => ($envOptions['DB_USERNAME'] ?? ''),
+ 'password' => ($envOptions['DB_PASSWORD'] ?? ''),
+ 'database' => ($envOptions['DB_DATABASE'] ?? ''),
];
- $port = $_SERVER['DB_PORT'] ?? '';
+ $port = $envOptions['DB_PORT'] ?? '';
if ($port) {
$dbOptions['host'] .= ':' . $port;
}
}
}
- // Create a mysqldump for the BookStack database
- $executableFinder = new ExecutableFinder();
- $mysqldumpPath = $executableFinder->find('mysqldump', '/usr/bin/mysqldump');
-
- if (!is_file($mysqldumpPath)) {
- throw new CommandError('Could not locate "mysqldump" program');
- }
-
- $process = new Process([
- $mysqldumpPath,
- '-h', $dbOptions['host'],
- '-u', $dbOptions['username'],
- '-p' . $dbOptions['password'],
- '--single-transaction',
- '--no-tablespaces',
- $dbOptions['database'],
- ]);
- $process->setTimeout(240);
- $process->setIdleTimeout(5);
- $process->start();
-
$errors = "";
$hasOutput = false;
- $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
+ $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
$dumpTempFileResource = fopen($dumpTempFile, 'w');
+
try {
- foreach ($process as $type => $data) {
- if ($process::OUT === $type) {
+ (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;
- } else { // $process::ERR === $type
- $errors .= $data . "\n";
- }
- }
- } catch (ProcessTimedOutException $timedOutException) {
+ }, function ($error) use (&$errors) {
+ $errors .= $error . "\n";
+ });
+ } catch (\Exception $exception) {
fclose($dumpTempFileResource);
unlink($dumpTempFile);
- 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.");
+ 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);