2023-03-02 21:44:08 -05:00
|
|
|
<?php
|
|
|
|
|
|
|
|
namespace Cli\Commands;
|
|
|
|
|
2023-03-04 10:26:27 -05:00
|
|
|
use Cli\Services\EnvironmentLoader;
|
2023-03-04 10:06:38 -05:00
|
|
|
use Cli\Services\ProgramRunner;
|
2023-03-02 21:44:08 -05:00
|
|
|
use Minicli\Command\CommandCall;
|
|
|
|
use RecursiveDirectoryIterator;
|
2023-03-03 12:22:24 -05:00
|
|
|
use SplFileInfo;
|
2023-03-03 16:01:30 -05:00
|
|
|
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
2023-03-02 21:44:08 -05:00
|
|
|
use ZipArchive;
|
|
|
|
|
|
|
|
final class BackupCommand
|
|
|
|
{
|
|
|
|
public function __construct(
|
|
|
|
protected string $appDir
|
|
|
|
) {
|
|
|
|
}
|
|
|
|
|
2023-03-03 16:01:30 -05:00
|
|
|
/**
|
|
|
|
* @throws CommandError
|
|
|
|
*/
|
2023-03-02 21:44:08 -05:00
|
|
|
public function handle(CommandCall $input)
|
|
|
|
{
|
2023-03-03 16:01:30 -05:00
|
|
|
$this->ensureRequiredExtensionInstalled();
|
|
|
|
|
2023-03-03 12:22:24 -05:00
|
|
|
$handleDatabase = !$input->hasFlag('no-database');
|
|
|
|
$handleUploads = !$input->hasFlag('no-uploads');
|
2023-03-03 16:01:30 -05:00
|
|
|
$handleThemes = !$input->hasFlag('no-themes');
|
|
|
|
$suggestedOutPath = $input->subcommand;
|
|
|
|
if ($suggestedOutPath === 'default') {
|
|
|
|
$suggestedOutPath = '';
|
|
|
|
}
|
2023-03-02 21:44:08 -05:00
|
|
|
|
2023-03-03 12:22:24 -05:00
|
|
|
$zipOutFile = $this->buildZipFilePath($suggestedOutPath);
|
|
|
|
|
|
|
|
// Create a new ZIP file
|
|
|
|
$zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
|
2023-03-04 10:06:38 -05:00
|
|
|
$dumpTempFile = '';
|
2023-03-03 12:22:24 -05:00
|
|
|
$zip = new ZipArchive();
|
|
|
|
$zip->open($zipTempFile, ZipArchive::CREATE);
|
2023-03-03 16:01:30 -05:00
|
|
|
|
|
|
|
// Add default files (.env config file and this CLI)
|
2023-03-03 12:22:24 -05:00
|
|
|
$zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
|
2023-03-03 16:01:30 -05:00
|
|
|
$zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
|
2023-03-03 12:22:24 -05:00
|
|
|
|
|
|
|
if ($handleDatabase) {
|
2023-03-03 16:01:30 -05:00
|
|
|
echo "Dumping the database via mysqldump...\n";
|
|
|
|
$dumpTempFile = $this->createDatabaseDump();
|
|
|
|
echo "Adding database dump to backup archive...\n";
|
2023-03-03 12:22:24 -05:00
|
|
|
$zip->addFile($dumpTempFile, 'db.sql');
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($handleUploads) {
|
2023-03-03 16:01:30 -05:00
|
|
|
echo "Adding BookStack upload folders to backup archive...\n";
|
2023-03-03 12:22:24 -05:00
|
|
|
$this->addUploadFoldersToZip($zip);
|
|
|
|
}
|
|
|
|
|
2023-03-03 16:01:30 -05:00
|
|
|
if ($handleThemes) {
|
|
|
|
echo "Adding BookStack theme folders to backup archive...\n";
|
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
|
|
|
|
}
|
|
|
|
|
2023-03-03 12:22:24 -05:00
|
|
|
// Close off our zip and move it to the required location
|
|
|
|
$zip->close();
|
2023-03-04 10:06:38 -05: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 12:22:24 -05:00
|
|
|
rename($zipTempFile, $zipOutFile);
|
|
|
|
|
2023-03-03 21:40:29 -05:00
|
|
|
// Announce end
|
2023-03-03 16:01:30 -05:00
|
|
|
echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 12:22:24 -05: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 16:01:30 -05:00
|
|
|
* @throws CommandError
|
2023-03-03 12:22:24 -05:00
|
|
|
*/
|
|
|
|
protected function buildZipFilePath(string $suggestedOutPath): string
|
|
|
|
{
|
|
|
|
$zipDir = getcwd() ?: $this->appDir;
|
|
|
|
$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 16:01:30 -05:00
|
|
|
throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
|
2023-03-03 12:22:24 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
|
2023-03-02 21:44:08 -05:00
|
|
|
|
2023-03-03 12:22:24 -05:00
|
|
|
if (file_exists($fullPath)) {
|
2023-03-03 16:01:30 -05:00
|
|
|
throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
|
2023-03-03 12:22:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
return $fullPath;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
{
|
2023-03-03 16:01:30 -05:00
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
|
|
|
|
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
|
|
|
|
}
|
2023-03-03 12:22:24 -05:00
|
|
|
|
2023-03-03 16:01:30 -05: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 12:22:24 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a database dump and return the path to the dumped SQL output.
|
2023-03-03 16:01:30 -05:00
|
|
|
* @throws CommandError
|
2023-03-03 12:22:24 -05:00
|
|
|
*/
|
|
|
|
protected function createDatabaseDump(): string
|
|
|
|
{
|
2023-03-04 10:26:27 -05:00
|
|
|
$envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($this->appDir);
|
2023-03-03 16:01:30 -05:00
|
|
|
$dbOptions = [
|
2023-03-04 10:26:27 -05:00
|
|
|
'host' => ($envOptions['DB_HOST'] ?? ''),
|
|
|
|
'username' => ($envOptions['DB_USERNAME'] ?? ''),
|
|
|
|
'password' => ($envOptions['DB_PASSWORD'] ?? ''),
|
|
|
|
'database' => ($envOptions['DB_DATABASE'] ?? ''),
|
2023-03-03 16:01:30 -05:00
|
|
|
];
|
|
|
|
|
2023-03-04 10:26:27 -05:00
|
|
|
$port = $envOptions['DB_PORT'] ?? '';
|
2023-03-03 16:16:15 -05:00
|
|
|
if ($port) {
|
|
|
|
$dbOptions['host'] .= ':' . $port;
|
|
|
|
}
|
|
|
|
|
2023-03-03 16:01:30 -05:00
|
|
|
foreach ($dbOptions as $name => $option) {
|
|
|
|
if (!$option) {
|
|
|
|
throw new CommandError("Could not find a value for the database {$name}");
|
|
|
|
}
|
|
|
|
}
|
2023-03-02 21:44:08 -05:00
|
|
|
|
|
|
|
$errors = "";
|
2023-03-03 16:01:30 -05:00
|
|
|
$hasOutput = false;
|
2023-03-04 10:06:38 -05:00
|
|
|
$dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
|
2023-03-02 21:44:08 -05:00
|
|
|
$dumpTempFileResource = fopen($dumpTempFile, 'w');
|
2023-03-04 10:06:38 -05:00
|
|
|
|
2023-03-03 16:01:30 -05:00
|
|
|
try {
|
2023-03-04 10:06:38 -05:00
|
|
|
(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) {
|
2023-03-03 16:01:30 -05:00
|
|
|
fwrite($dumpTempFileResource, $data);
|
|
|
|
$hasOutput = true;
|
2023-03-04 10:06:38 -05:00
|
|
|
}, function ($error) use (&$errors) {
|
|
|
|
$errors .= $error . "\n";
|
|
|
|
});
|
|
|
|
} catch (\Exception $exception) {
|
2023-03-03 16:01:30 -05:00
|
|
|
fclose($dumpTempFileResource);
|
|
|
|
unlink($dumpTempFile);
|
2023-03-04 10:06:38 -05:00
|
|
|
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.");
|
|
|
|
}
|
2023-03-02 21:44:08 -05:00
|
|
|
}
|
2023-03-04 10:06:38 -05:00
|
|
|
throw new CommandError($exception->getMessage());
|
2023-03-02 21:44:08 -05:00
|
|
|
}
|
2023-03-03 16:01:30 -05:00
|
|
|
|
2023-03-02 21:44:08 -05:00
|
|
|
fclose($dumpTempFileResource);
|
|
|
|
|
2023-03-03 16:01:30 -05:00
|
|
|
if ($errors) {
|
|
|
|
unlink($dumpTempFile);
|
|
|
|
throw new CommandError("Failed mysqldump with errors:\n" . $errors);
|
|
|
|
}
|
|
|
|
|
2023-03-03 12:22:24 -05:00
|
|
|
return $dumpTempFile;
|
2023-03-02 21:44:08 -05:00
|
|
|
}
|
|
|
|
}
|