Compare commits

...

16 commits

Author SHA1 Message Date
Dan Brown
c84d8aa4c1
Got restore command to a working state 2023-03-09 14:42:28 +00:00
Dan Brown
0c14f22831
Progressed restore command to almost working state 2023-03-07 18:10:44 +00:00
Dan Brown
74b5fadf60
Progressed restore command a little 2023-03-07 12:05:04 +00:00
Dan Brown
2894ce275c
Started restore command, extracted artisan command 2023-03-06 21:39:22 +00:00
Dan Brown
6bfbad2393
Added central way to resolve app path, improved ouput formatting 2023-03-06 17:35:23 +00:00
Dan Brown
3b6de8872a
Added update command
Extracted some common parts to their own service files
2023-03-06 14:55:41 +00:00
Dan Brown
0be1bf7499
Migrated to symfony/console 2023-03-05 15:28:02 +00:00
Dan Brown
4d9d591792
Added dep check and composer auto-install to init command 2023-03-04 19:23:44 +00:00
Dan Brown
21db0ebf46
Updated env loading to be contained/controlled for usage 2023-03-04 15:26:27 +00:00
Dan Brown
cbf77ecdbf
Extracted program running to its own class 2023-03-04 15:06:38 +00:00
Dan Brown
3cc761d2d8
Added "init" command to admin-cli
Got to basic working state, some todos in there.
2023-03-04 02:40:29 +00:00
Dan Brown
ff0c183a66
Added db port support for backup command 2023-03-03 21:16:15 +00:00
Dan Brown
ead5ebbee1
Added error handling and validation to backup command 2023-03-03 21:01:30 +00:00
Dan Brown
6b9732588c
Split command out to methods, added flags and out path options 2023-03-03 17:22:24 +00:00
Dan Brown
f77d4ce295
Outlined a general working backup approach 2023-03-03 02:44:08 +00:00
Dan Brown
6d055e6f72
Started playing with a seperate admin-cli
Just playing with libraries & phar-usage right now
2023-03-02 16:31:27 +00:00
20 changed files with 2329 additions and 1 deletions

1
.gitignore vendored
View file

@ -26,3 +26,4 @@ webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon
/composer

2
scripts/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.phar
vendor/

View file

@ -0,0 +1,176 @@
<?php
namespace Cli\Commands;
use Cli\Services\AppLocator;
use Cli\Services\EnvironmentLoader;
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 ZipArchive;
final class BackupCommand extends Command
{
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");
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
}
/**
* @throws CommandError
*/
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->getOption('no-database');
$handleUploads = !$input->getOption('no-uploads');
$handleThemes = !$input->getOption('no-themes');
$suggestedOutPath = $input->getArgument('backup-path');
$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($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
$zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
if ($handleDatabase) {
$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');
}
if ($handleUploads) {
$output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
$this->addUploadFoldersToZip($zip, $appDir);
}
if ($handleThemes) {
$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
$output->writeln("<info>Backup finished.</info>");
$output->writeln("Output ZIP saved to: {$zipOutFile}");
return Command::SUCCESS;
}
/**
* 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');
}
}
/**
* 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.
* @throws CommandError
*/
protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
{
$zipDir = getcwd() ?: $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 {
throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
}
}
$fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
if (file_exists($fullPath)) {
throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
}
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, string $appDir): void
{
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
}
/**
* 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());
}
}
}
/**
* Create a database dump and return the path to the dumped SQL output.
* @throws CommandError
*/
protected function createDatabaseDump(string $appDir): string
{
$envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
$mysql = MySqlRunner::fromEnvOptions($envOptions);
$mysql->ensureOptionsSet();
$dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
try {
$mysql->runDumpToFile($dumpTempFile);
} catch (\Exception $exception) {
unlink($dumpTempFile);
throw new CommandError($exception->getMessage());
}
return $dumpTempFile;
}
}

View file

@ -0,0 +1,5 @@
<?php
namespace Cli\Commands;
class CommandError extends \Exception {}

View file

@ -0,0 +1,173 @@
<?php
namespace Cli\Commands;
use Cli\Services\ComposerLocator;
use Cli\Services\EnvironmentLoader;
use Cli\Services\ProgramRunner;
use Cli\Services\RequirementsValidator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class InitCommand extends Command
{
protected function configure(): void
{
$this->setName('init');
$this->setDescription('Initialise a new BookStack install. Does not configure the webserver or database.');
$this->addArgument('target-directory', InputArgument::OPTIONAL, 'The directory to create the BookStack install within. Must be empty.', '');
}
/**
* @throws CommandError
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln("<info>Checking system requirements...</info>");
RequirementsValidator::validate();
$suggestedOutPath = $input->getArgument('target-directory');
$output->writeln("<info>Locating and checking install directory...</info>");
$installDir = $this->getInstallDir($suggestedOutPath);
$this->ensureInstallDirEmptyAndWritable($installDir);
$output->writeln("<info>Cloning down BookStack project to install directory...</info>");
$this->cloneBookStackViaGit($installDir);
$output->writeln("<info>Checking composer exists...</info>");
$composerLocator = new ComposerLocator($installDir);
$composer = $composerLocator->getProgram();
if (!$composer->isFound()) {
$output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
$composerLocator->download();
}
$output->writeln("<info>Installing application dependencies using composer...</info>");
$this->installComposerDependencies($composer, $installDir);
$output->writeln("<info>Creating .env file from .env.example...</info>");
copy($installDir . DIRECTORY_SEPARATOR . '.env.example', $installDir . DIRECTORY_SEPARATOR . '.env');
sleep(1);
$output->writeln("<info>Generating app key...</info>");
$this->generateAppKey($installDir);
// Announce end
$output->writeln("<info>A BookStack install has been initialized at: {$installDir}\n</info>");
$output->writeln("<info>You will still need to:</info>");
$output->writeln("<info>- Update the .env file in the install with correct URL, database and email details.</info>");
$output->writeln("<info>- Run 'php artisan migrate' to set-up the database.</info>");
$output->writeln("<info>- Configure your webserver for use with BookStack.</info>");
$output->writeln("<info>- Ensure the required directories (storage/ bootstrap/cache public/uploads) are web-server writable.</info>");
return Command::SUCCESS;
}
protected function generateAppKey(string $installDir): void
{
$errors = (new ProgramRunner('php', '/usr/bin/php'))
->withTimeout(60)
->withIdleTimeout(5)
->withEnvironment(EnvironmentLoader::load($installDir))
->runCapturingAllOutput([
$installDir . DIRECTORY_SEPARATOR . 'artisan',
'key:generate', '--force', '-n', '-q'
]);
if ($errors) {
throw new CommandError("Failed 'php artisan key:generate' with errors:\n" . $errors);
}
}
/**
* Run composer install to download PHP dependencies.
* @throws CommandError
*/
protected function installComposerDependencies(ProgramRunner $composer, string $installDir): void
{
$errors = $composer->runCapturingStdErr([
'install',
'--no-dev', '-n', '-q', '--no-progress',
'-d', $installDir
]);
if ($errors) {
throw new CommandError("Failed composer install with errors:\n" . $errors);
}
}
/**
* Clone a new instance of BookStack to the given install folder.
* @throws CommandError
*/
protected function cloneBookStackViaGit(string $installDir): void
{
$git = (new ProgramRunner('git', '/usr/bin/git'))
->withTimeout(240)
->withIdleTimeout(15);
$errors = $git->runCapturingStdErr([
'clone', '-q',
'--branch', 'release',
'--single-branch',
'https://github.com/BookStackApp/BookStack.git',
$installDir
]);
if ($errors) {
throw new CommandError("Failed git clone with errors:\n" . $errors);
}
// Disable file permission tracking for git repo
$git->runCapturingStdErr([
'-C', $installDir,
'config', 'core.fileMode', 'false'
]);
}
/**
* Ensure that the installation directory is completely empty to avoid potential conflicts or issues.
* @throws CommandError
*/
protected function ensureInstallDirEmptyAndWritable(string $installDir): void
{
$contents = array_diff(scandir($installDir), ['..', '.']);
if (count($contents) > 0) {
throw new CommandError("Expected install directory to be empty but existing files found in [{$installDir}] target location.");
}
if (!is_writable($installDir)) {
throw new CommandError("Target install directory [{$installDir}] is not writable.");
}
}
/**
* Build a full path to the intended location for the BookStack install.
* @throws CommandError
*/
protected function getInstallDir(string $suggestedDir): string
{
$dir = getcwd();
if ($suggestedDir) {
if (is_file($suggestedDir)) {
throw new CommandError("Was provided [{$suggestedDir}] as an install path but existing file provided.");
} else if (is_dir($suggestedDir)) {
$dir = realpath($suggestedDir);
} else if (is_dir(dirname($suggestedDir))) {
$created = mkdir($suggestedDir);
if (!$created) {
throw new CommandError("Could not create directory [{$suggestedDir}] for install.");
}
$dir = realpath($suggestedDir);
} else {
throw new CommandError("Could not resolve provided [{$suggestedDir}] path to an existing folder.");
}
}
return $dir;
}
}

View file

@ -0,0 +1,204 @@
<?php
namespace Cli\Commands;
use Cli\Services\AppLocator;
use Cli\Services\ArtisanRunner;
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;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class RestoreCommand extends Command
{
protected function configure(): void
{
$this->setName('restore');
$this->addArgument('backup-zip', InputArgument::REQUIRED, 'Path to the ZIP file containing your backup.');
$this->setDescription('Restore data and files from a backup ZIP file.');
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to restore into', '');
}
/**
* @throws CommandError
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
$output->writeln("<info>Warning!</info>");
$output->writeln("<info>- A restore operation will overwrite and remove files & content from an existing instance.</info>");
$output->writeln("<info>- Any existing tables within the configured database will be dropped.</info>");
$output->writeln("<info>- You should only restore into an instance of the same or newer BookStack version.</info>");
$output->writeln("<info>- This command won't handle, restore or address any server configuration.</info>");
$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);
$contents = $zip->getContentsOverview();
$output->writeln("\n<info>Contents found in the backup ZIP:</info>");
$hasContent = false;
foreach ($contents as $info) {
$output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
if ($info['exists']) {
$hasContent = true;
}
}
if (!$hasContent) {
throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restore-able content.");
}
$output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
$output->writeln("<info>Existing content may be overwritten.</info>");
if (!$interactions->confirm("Do you want to continue?")) {
$output->writeln("<info>Stopping restore operation.</info>");
return Command::SUCCESS;
}
$output->writeln("<info>Extracting ZIP into temporary directory...</info>");
$extractDir = $appDir . DIRECTORY_SEPARATOR . 'restore-temp-' . time();
if (!mkdir($extractDir)) {
throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
}
$zip->extractInto($extractDir);
$envChanges = [];
if ($contents['env']['exists']) {
$output->writeln("<info>Restoring and merging .env file...</info>");
$envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
}
$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);
}
}
$artisan = (new ArtisanRunner($appDir));
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->run(['migrate', '--force']);
}
if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
$output->writeln("<info>App URL change made, Updating database with URL change...</info>");
$artisan->run([
'bookstack:update-url',
$envChanges['old_url'], $envChanges['new_url'],
]);
}
$output->writeln("<info>Clearing app caches...</info>");
$artisan->run(['cache:clear']);
$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, OutputInterface $output, InteractiveConsole $interactions): array
{
$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");
}
// 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');
}
$oldUrl = $oldEnv['APP_URL'] ?? '';
$newUrl = $currentEnv['APP_URL'] ?? '';
$returnData = [
'old_url' => $oldUrl,
'new_url' => $oldUrl,
];
if ($oldUrl !== $newUrl) {
$output->writeln("Found different APP_URL values:");
$changedUrl = $interactions->choice('Which would you like to use?', array_filter([$oldUrl, $newUrl]));
$envContents = preg_replace('/^APP_URL=.*?$/', 'APP_URL="' . $changedUrl . '"', $envContents);
$returnData['new_url'] = $changedUrl;
}
file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
return $returnData;
}
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);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace Cli\Commands;
use Cli\Services\AppLocator;
use Cli\Services\ArtisanRunner;
use Cli\Services\ComposerLocator;
use Cli\Services\ProgramRunner;
use Cli\Services\RequirementsValidator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class UpdateCommand extends Command
{
protected function configure(): void
{
$this->setName('update');
$this->setDescription('Update an existing BookStack instance.');
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to update', '');
}
/**
* @throws CommandError
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("<info>Checking system requirements...</info>");
RequirementsValidator::validate();
$output->writeln("<info>Checking composer exists...</info>");
$composerLocator = new ComposerLocator($appDir);
$composer = $composerLocator->getProgram();
if (!$composer->isFound()) {
$output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
$composerLocator->download();
}
$output->writeln("<info>Fetching latest code via Git...</info>");
$this->updateCodeUsingGit($appDir);
$output->writeln("<info>Installing PHP dependencies via composer...</info>");
$this->installComposerDependencies($composer, $appDir);
$output->writeln("<info>Running database migrations...</info>");
$artisan = (new ArtisanRunner($appDir));
$artisan->run(['migrate', '--force']);
$output->writeln("<info>Clearing app caches...</info>");
$artisan->run(['cache:clear']);
$artisan->run(['config:clear']);
$artisan->run(['view:clear']);
return Command::SUCCESS;
}
/**
* @throws CommandError
*/
protected function updateCodeUsingGit(string $appDir): void
{
$errors = (new ProgramRunner('git', '/usr/bin/git'))
->withTimeout(240)
->withIdleTimeout(15)
->runCapturingStdErr([
'-C', $appDir,
'pull', '-q', 'origin', 'release',
]);
if ($errors) {
throw new CommandError("Failed git pull with errors:\n" . $errors);
}
}
/**
* @throws CommandError
*/
protected function installComposerDependencies(ProgramRunner $composer, string $appDir): void
{
$errors = $composer->runCapturingStdErr([
'install',
'--no-dev', '-n', '-q', '--no-progress',
'-d', $appDir,
]);
if ($errors) {
throw new CommandError("Failed composer install with errors:\n" . $errors);
}
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Cli\Services;
use Phar;
class AppLocator
{
public static function search(string $directory = ''): string
{
$directoriesToSearch = $directory ? [$directory] : [
getcwd(),
static::getCliDirectory(),
];
foreach ($directoriesToSearch as $directory) {
if ($directory && static::isProbablyAppDirectory($directory)) {
return $directory;
}
}
return '';
}
public static function require(string $directory = ''): string
{
$dir = static::search($directory);
if (!$dir) {
throw new \Exception('Could not find a valid BookStack installation');
}
return $dir;
}
protected static function getCliDirectory(): string
{
$scriptDir = dirname(__DIR__);
if (str_starts_with($scriptDir, 'phar://')) {
$scriptDir = dirname(Phar::running(false));
}
return dirname($scriptDir);
}
protected static function isProbablyAppDirectory(string $directory): bool
{
return file_exists($directory . DIRECTORY_SEPARATOR . 'version')
&& file_exists($directory . DIRECTORY_SEPARATOR . 'package.json');
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Cli\Services;
use Exception;
class ArtisanRunner
{
public function __construct(
protected string $appDir
) {
}
public function run(array $commandArgs)
{
$errors = (new ProgramRunner('php', '/usr/bin/php'))
->withTimeout(60)
->withIdleTimeout(5)
->withEnvironment(EnvironmentLoader::load($this->appDir))
->runCapturingAllOutput([
$this->appDir . DIRECTORY_SEPARATOR . 'artisan',
'-n', '-q',
...$commandArgs
]);
if ($errors) {
$cmdString = implode(' ', $commandArgs);
throw new Exception("Failed 'php artisan {$cmdString}' with errors:\n" . $errors);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace Cli\Services;
use ZipArchive;
class BackupZip
{
protected ZipArchive $zip;
public function __construct(
protected string $filePath
) {
$this->zip = new ZipArchive();
$status = $this->zip->open($this->filePath);
if (!file_exists($this->filePath) || $status !== true) {
throw new \Exception("Could not open file [{$this->filePath}] as ZIP");
}
}
/**
* @return array<string, array{desc: string, exists: bool}>
*/
public function getContentsOverview(): array
{
return [
'env' => [
'desc' => '.env Config File',
'exists' => boolval($this->zip->statName('.env')),
],
'themes' => [
'desc' => 'Themes Folder',
'exists' => $this->hasFolder('themes/'),
],
'public/uploads' => [
'desc' => 'Public File Uploads',
'exists' => $this->hasFolder('public/uploads/'),
],
'storage/uploads' => [
'desc' => 'Private File Uploads',
'exists' => $this->hasFolder('storage/uploads/'),
],
'db' => [
'desc' => 'Database Dump',
'exists' => boolval($this->zip->statName('db.sql')),
],
];
}
public function extractInto(string $directoryPath): void
{
$result = $this->zip->extractTo($directoryPath);
if (!$result) {
throw new \Exception("Failed extraction of ZIP into [{$directoryPath}].");
}
}
protected function hasFolder($folderPath): bool
{
for ($i = 0; $i < $this->zip->numFiles; $i++) {
$filePath = $this->zip->getNameIndex($i);
if (str_starts_with($filePath, $folderPath)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Cli\Services;
use Exception;
class ComposerLocator
{
public function __construct(
protected string $appDir
) {
}
public function getProgram(): ProgramRunner
{
return (new ProgramRunner('composer', '/usr/local/bin/composer'))
->withTimeout(300)
->withIdleTimeout(15)
->withAdditionalPathLocation($this->appDir);
}
/**
* @throws Exception
*/
public function download(): void
{
$setupPath = $this->appDir . DIRECTORY_SEPARATOR . 'composer-setup.php';
$signature = file_get_contents('https://composer.github.io/installer.sig');
copy('https://getcomposer.org/installer', $setupPath);
$checksum = hash_file('sha384', $setupPath);
if ($signature !== $checksum) {
unlink($setupPath);
throw new Exception("Could not install composer, checksum validation failed.");
}
$status = (new ProgramRunner('php', '/usr/bin/php'))
->runWithoutOutputCallbacks([
$setupPath, '--quiet',
"--install-dir={$this->appDir}",
"--filename=composer",
]);
unlink($setupPath);
if ($status !== 0) {
throw new Exception("Could not install composer, composer-setup script run failed.");
}
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Cli\Services;
use Dotenv\Dotenv;
class EnvironmentLoader
{
public static function load(string $rootPath): array
{
$dotenv = Dotenv::createArrayBacked($rootPath);
return $dotenv->safeLoad();
}
public static function loadMergedWithCurrentEnv(string $rootPath): array
{
$env = static::load($rootPath);
foreach ($_SERVER as $key => $val) {
$env[$key] = $val;
}
return $env;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Cli\Services;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class InteractiveConsole
{
public function __construct(
protected QuestionHelper $helper,
protected InputInterface $input,
protected OutputInterface $output,
) {
}
public function confirm(string $text): bool
{
$question = new ConfirmationQuestion($text . " (y/n)\n", false);
return $this->helper->ask($this->input, $this->output, $question);
}
public function choice(string $question, array $answers)
{
$question = new ChoiceQuestion($question, $answers, $answers[0]);
return $this->helper->ask($this->input, $this->output, $question);
}
}

View file

@ -0,0 +1,120 @@
<?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);
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Cli\Services;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
class ProgramRunner
{
protected int $timeout = 240;
protected int $idleTimeout = 15;
protected array $environment = [];
protected array $additionalProgramDirectories = [];
public function __construct(
protected string $program,
protected string $defaultPath
) {
}
public function withTimeout(int $timeoutSeconds): static
{
$this->timeout = $timeoutSeconds;
return $this;
}
public function withIdleTimeout(int $idleTimeoutSeconds): static
{
$this->idleTimeout = $idleTimeoutSeconds;
return $this;
}
public function withEnvironment(array $environment): static
{
$this->environment = $environment;
return $this;
}
public function withAdditionalPathLocation(string $directoryPath): static
{
$this->additionalProgramDirectories[] = $directoryPath;
return $this;
}
public function runCapturingAllOutput(array $args): string
{
$output = '';
$callable = function ($data) use (&$output) {
$output .= $data;
};
$this->runWithoutOutputCallbacks($args, $callable, $callable);
return $output;
}
public function runCapturingStdErr(array $args): string
{
$err = '';
$this->runWithoutOutputCallbacks($args, fn() => '', function ($data) use (&$err) {
$err .= $data;
});
return $err;
}
public function runWithoutOutputCallbacks(array $args, callable $stdOutCallback = null, callable $stdErrCallback = null): int
{
$process = $this->startProcess($args);
foreach ($process as $type => $data) {
if ($type === $process::ERR) {
if ($stdErrCallback) {
$stdErrCallback($data);
}
} else {
if ($stdOutCallback) {
$stdOutCallback($data);
}
}
}
return $process->getExitCode() ?? 1;
}
/**
* @throws \Exception
*/
public function ensureFound(): void
{
$this->resolveProgramPath();
}
public function isFound(): bool
{
try {
$this->ensureFound();
return true;
} catch (\Exception $exception) {
return false;
}
}
protected function startProcess(array $args): Process
{
$programPath = $this->resolveProgramPath();
$process = new Process([$programPath, ...$args], null, $this->environment);
$process->setTimeout($this->timeout);
$process->setIdleTimeout($this->idleTimeout);
$process->start();
return $process;
}
protected function resolveProgramPath(): string
{
$executableFinder = new ExecutableFinder();
$path = $executableFinder->find($this->program, $this->defaultPath, $this->additionalProgramDirectories);
if (is_null($path) || !is_file($path)) {
throw new \Exception("Could not locate \"{$this->program}\" program.");
}
return $path;
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Cli\Services;
use Exception;
class RequirementsValidator
{
/**
* Ensure the required PHP extensions are installed for this command.
* @throws Exception
*/
public static function validate(): void
{
$errors = [];
if (version_compare(PHP_VERSION, '8.0.2') < 0) {
$errors[] = "PHP >= 8.0.2 is required to install BookStack.";
}
$requiredExtensions = ['bcmath', 'curl', 'gd', 'iconv', 'libxml', 'mbstring', 'mysqlnd', 'xml'];
foreach ($requiredExtensions as $extension) {
if (!extension_loaded($extension)) {
$errors[] = "The \"{$extension}\" PHP extension is required by not active.";
}
}
try {
(new ProgramRunner('git', '/usr/bin/git'))->ensureFound();
(new ProgramRunner('php', '/usr/bin/php'))->ensureFound();
} catch (Exception $exception) {
$errors[] = $exception->getMessage();
}
if (count($errors) > 0) {
throw new Exception("Requirements failed with following errors:\n" . implode("\n", $errors));
}
}
}

52
scripts/compile.php Normal file
View file

@ -0,0 +1,52 @@
<?php
/**
* This file builds a phar archive to contain our CLI code.
* Attribution to https://blog.programster.org/creating-phar-files
* for the code in this file.
*/
try {
$pharFile = 'app.phar';
// clean up
if (file_exists($pharFile)) {
unlink($pharFile);
}
if (file_exists($pharFile . '.gz')) {
unlink($pharFile . '.gz');
}
// create phar
$phar = new Phar($pharFile);
// start buffering. Mandatory to modify stub to add shebang
$phar->startBuffering();
// Create the default stub from main.php entrypoint
$defaultStub = $phar->createDefaultStub('run');
// Add the rest of the apps files
$phar->addFile(__DIR__ . '/run', 'run');
$phar->buildFromDirectory(__DIR__, '/vendor(.*)/');
$phar->buildFromDirectory(__DIR__, '/Commands(.*)/');
// Customize the stub to add the shebang
$stub = "#!/usr/bin/env php \n" . $defaultStub;
// Add the stub
$phar->setStub($stub);
$phar->stopBuffering();
// plus - compressing it into gzip
$phar->compressFiles(Phar::GZ);
# Make the file executable
chmod(__DIR__ . "/{$pharFile}", 0770);
echo "$pharFile successfully created" . PHP_EOL;
} catch (Exception $e) {
echo $e->getMessage();
}

21
scripts/composer.json Normal file
View file

@ -0,0 +1,21 @@
{
"require": {
"symfony/console": "^6.0",
"symfony/process": "^6.0",
"vlucas/phpdotenv": "^5.5"
},
"autoload": {
"psr-4": {
"Cli\\Commands\\": "Commands/",
"Cli\\Services\\": "Services/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.0.2"
}
}
}

1032
scripts/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

35
scripts/run Normal file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env php
<?php
if (php_sapi_name() !== 'cli') {
exit;
}
require __DIR__ . '/vendor/autoload.php';
use Cli\Commands\BackupCommand;
use Cli\Commands\InitCommand;
use Cli\Commands\RestoreCommand;
use Cli\Commands\UpdateCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\ConsoleOutput;
// Setup our CLI
$app = new Application('bookstack-system');
$app->setCatchExceptions(false);
$app->add(new BackupCommand());
$app->add(new UpdateCommand());
$app->add(new InitCommand());
$app->add(new RestoreCommand());
try {
$app->run();
} catch (Exception $error) {
$output = (new ConsoleOutput())->getErrorOutput();
$output->getFormatter()->setStyle('error', new OutputFormatterStyle('red'));
$output->writeln("<error>\nAn error occurred when attempting to run a command:\n</error>");
$output->writeln($error->getMessage());
exit(1);
}