- Fixed some relative paths issues when ran in phar.
- Added helper class for doing path work.
- Updated composer runner timeout.
- Updated checks for paths more thorough.
- Removed default completion command.
--- /dev/null
+<?php
+declare(strict_types=1);
+
+namespace Cli;
+
+use Symfony\Component\Console\Application as BaseApplication;
+use Symfony\Component\Console\Command\HelpCommand;
+use Symfony\Component\Console\Command\ListCommand;
+
+class Application extends BaseApplication
+{
+ protected function getDefaultCommands(): array
+ {
+ return [new HelpCommand(), new ListCommand()];
+ }
+}
\ No newline at end of file
use Cli\Services\AppLocator;
use Cli\Services\EnvironmentLoader;
use Cli\Services\MySqlRunner;
+use Cli\Services\Paths;
use RecursiveDirectoryIterator;
use SplFileInfo;
use Symfony\Component\Console\Command\Command;
$zip->open($zipTempFile, ZipArchive::CREATE);
// Add default files (.env config file and this CLI if existing)
- $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
- $cliPath = $appDir . DIRECTORY_SEPARATOR . 'bookstack-system-cli';
+ $zip->addFile(Paths::join($appDir, '.env'), '.env');
+ $cliPath = Paths::join($appDir, 'bookstack-system-cli');
if (file_exists($cliPath)) {
$zip->addFile($cliPath, 'bookstack-system-cli');
}
if ($handleThemes) {
$output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
+ $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
}
// Close off our zip and move it to the required location
/**
* 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.
+ * Targets the <app>/backups directory by default if existing, otherwise <app>.
* @throws CommandError
*/
protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
{
- $zipDir = getcwd() ?: $appDir;
+ $suggestedOutPath = Paths::resolve($suggestedOutPath);
+ $zipDir = Paths::join($appDir, 'backups');
$zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
if ($suggestedOutPath) {
} else {
throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
}
+ } else {
+ if (!is_dir($zipDir)) {
+ $zipDir = $appDir;
+ }
}
- $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
+ $fullPath = Paths::join($zipDir, $zipName);
if (file_exists($fullPath)) {
throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
+ } else if (!is_dir($zipDir)) {
+ throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
+ } else if (!is_writable($zipDir)) {
+ throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
}
return $fullPath;
*/
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');
+ $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
+ $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
}
/**
use Cli\Services\ComposerLocator;
use Cli\Services\EnvironmentLoader;
+use Cli\Services\Paths;
use Cli\Services\ProgramRunner;
use Cli\Services\RequirementsValidator;
use Symfony\Component\Console\Command\Command;
$this->installComposerDependencies($composer, $installDir);
$output->writeln("<info>Creating .env file from .env.example...</info>");
- copy($installDir . DIRECTORY_SEPARATOR . '.env.example', $installDir . DIRECTORY_SEPARATOR . '.env');
+ copy(Paths::join($installDir, '.env.example'), Paths::join($installDir, '.env'));
sleep(1);
$output->writeln("<info>Generating app key...</info>");
->withIdleTimeout(5)
->withEnvironment(EnvironmentLoader::load($installDir))
->runCapturingAllOutput([
- $installDir . DIRECTORY_SEPARATOR . 'artisan',
+ Paths::join($installDir, 'artisan'),
'key:generate', '--force', '-n', '-q'
]);
*/
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.");
+ $dir = Paths::resolve($suggestedDir);
+
+ if (is_file($dir)) {
+ throw new CommandError("Was provided [{$dir}] as an install path but existing file provided.");
+ } else if (is_dir($dir) && realpath($dir)) {
+ $dir = realpath($dir);
+ } else if (is_dir(dirname($dir))) {
+ $created = mkdir($dir);
+ if (!$created) {
+ throw new CommandError("Could not create directory [{$dir}] for install.");
}
+ $dir = realpath($dir);
+ } else {
+ throw new CommandError("Could not resolve provided [{$dir}] path to an existing folder.");
}
return $dir;
}
$output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
- $output->writeln("<info>Existing content may be overwritten.</info>");
+ $output->writeln("<info>Existing content will be overwritten.</info>");
if (!$interactions->confirm("Do you want to continue?")) {
$output->writeln("<info>Stopping restore operation.</info>");
{
public static function search(string $directory = ''): string
{
- $directoriesToSearch = $directory ? [$directory] : [
+ $directoriesToSearch = $directory ? [Paths::resolve($directory)] : [
getcwd(),
static::getCliDirectory(),
];
{
return (new ProgramRunner('composer', '/usr/local/bin/composer'))
->withTimeout(300)
- ->withIdleTimeout(15)
+ ->withIdleTimeout(60)
->withAdditionalPathLocation($this->appDir);
}
--- /dev/null
+<?php declare(strict_types=1);
+
+namespace Cli\Services;
+
+class Paths
+{
+
+ /**
+ * Join together the given path components.
+ * Does no resolving or cleaning.
+ * Only the $base will remain absolute if so,
+ * $parts are assumed to treated as non-absolute paths.
+ */
+ public static function join(string $base, string ...$parts): string
+ {
+ $outParts = [rtrim($base, '/\\')];
+ foreach ($parts as $part) {
+ $outParts[] = trim($part, '/\\');
+ }
+
+ return implode(DIRECTORY_SEPARATOR, $outParts);
+ }
+
+ /**
+ * Resolve the full path for the given path/sub-path.
+ * If the provided path is not absolute, it will be returned
+ * be resolved to the provided $base or current working directory if
+ * no $base is provided.
+ */
+ public static function resolve(string $path, string $base = ''): string
+ {
+ if (str_starts_with($path, '/') || str_starts_with($path, '\\')) {
+ return DIRECTORY_SEPARATOR . self::clean($path);
+ }
+
+ $base = rtrim($base ?: getcwd(), '/');
+ $joined = $base . '/' . $path;
+ $absoluteBase = (str_starts_with($base, '/') || str_starts_with($base, '\\'));
+ return ($absoluteBase ? '/' : '') . self::clean($joined);
+ }
+
+ /**
+ * Clean the given path so that all up/down navigations are resolved,
+ * and so its using system-correct directory separators.
+ * Credit to Sven Arduwie in PHP docs:
+ * https://www.php.net/manual/en/function.realpath.php#84012
+ */
+ private static function clean(string $path): string
+ {
+ $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
+ $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), 'strlen');
+ $absolutes = [];
+ foreach ($parts as $part) {
+ if ('.' == $part) continue;
+ if ('..' == $part) {
+ array_pop($absolutes);
+ } else {
+ $absolutes[] = $part;
+ }
+ }
+ return implode(DIRECTORY_SEPARATOR, $absolutes);
+ }
+}
\ No newline at end of file
$errors[] = "PHP >= 8.0.2 is required to install BookStack.";
}
- $requiredExtensions = ['bcmath', 'curl', 'gd', 'iconv', 'libxml', 'mbstring', 'mysqlnd', 'xml'];
+ $requiredExtensions = ['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.";
<?php
+declare(strict_types=1);
+use Cli\Application;
use Cli\Commands\BackupCommand;
use Cli\Commands\InitCommand;
use Cli\Commands\RestoreCommand;
use Cli\Commands\UpdateCommand;
-use Symfony\Component\Console\Application;
// Setup our CLI
$app = new Application('bookstack-system');
$app->add(new InitCommand());
$app->add(new RestoreCommand());
-
-return $app;
\ No newline at end of file
+return $app;
--- /dev/null
+<?php
+
+namespace Tests;
+
+use Cli\Services\Paths;
+
+class PathTest extends TestCase
+{
+
+ public function test_resolve()
+ {
+ $cwd = getcwd();
+ $this->assertEquals('/my/path', Paths::resolve('/my/path/'));
+ $this->assertEquals('/my/path', Paths::resolve('\\my\\path'));
+ $this->assertEquals('/my/path', Paths::resolve('/my/path'));
+ $this->assertEquals('/my/path', Paths::resolve('/my/cats/../path'));
+ $this->assertEquals('/my/path', Paths::resolve('/my/cats/.././path'));
+ $this->assertEquals('/my/path', Paths::resolve('/my/path', '/root'));
+ $this->assertEquals('/root/my/path', Paths::resolve('my/path', '/root'));
+ $this->assertEquals('/my/path', Paths::resolve('../my/path', '/root'));
+ $this->assertEquals("{$cwd}/my/path", Paths::resolve('my/path'));
+ $this->assertEquals("{$cwd}", Paths::resolve(''));
+ }
+
+ public function test_join()
+ {
+ $this->assertEquals('/my/path', Paths::join('/my/', 'path'));
+ $this->assertEquals('/my/path', Paths::join('/my/', '/path'));
+ $this->assertEquals('/my/path', Paths::join('/my', 'path'));
+ $this->assertEquals('/my/path', Paths::join('/my', 'path/'));
+ $this->assertEquals('my/path/to/here', Paths::join('my', 'path', 'to', 'here'));
+ $this->assertEquals('my', Paths::join('my'));
+ $this->assertEquals('my/path', Paths::join('my//', '//path//'));
+ $this->assertEquals('/my/path', Paths::join('/my//', '\\path\\'));
+ }
+}
\ No newline at end of file