setName('download-vendor');
$this->setDescription('Download and extract PHP vendor files in a BookStack instance.');
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to download into', '');
}
/**
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("Checking app version...");
$version = AppLocator::getVersion($appDir);
if (empty($version)) {
throw new CommandError("Could not determine instance BookStack version.");
}
$targetChecksum = $this->getTargetChecksum($appDir);
$output->writeln("Downloading ZIP from files.bookstackapp.com...");
$zip = $this->downloadVendorZip($version);
$output->writeln("Validating downloaded ZIP...");
$this->verifyZipChecksum($zip, $targetChecksum);
$output->writeln("Deleting existing vendor/ directory...");
try {
$this->deleteAppVendorFiles($appDir);
} catch (Exception $exception) {
unlink($zip);
throw $exception;
}
$output->writeln("Extracting ZIP into BookStack instance...");
$this->extractZip($zip, $appDir);
$output->writeln("Cleaning up old app services...");
$cleaned = $this->cleanupAppServices($appDir);
if (!$cleaned) {
$output->writeln("Failed to remove exising app services file");
}
$output->writeln("Successfully downloaded & extracted vendor files into BookStack instance!");
return Command::SUCCESS;
}
protected function cleanupAppServices(string $appDir): bool
{
$filesToClear = [
implode(DIRECTORY_SEPARATOR, [$appDir, 'bootstrap', 'cache', 'services.php']),
implode(DIRECTORY_SEPARATOR, [$appDir, 'bootstrap', 'cache', 'packages.php']),
];
$status = true;
foreach ($filesToClear as $file) {
if (file_exists($file)) {
if (@unlink($file) === false) {
$status = false;
}
}
}
return $status;
}
protected function extractZip(string $zipPath, string $appDir): void
{
$zip = new ZipArchive();
$opened = $zip->open($zipPath, ZipArchive::RDONLY);
$extracted = $zip->extractTo($appDir);
$closed = $zip->close();
unlink($zipPath);
if (!$opened || !$extracted || !$closed) {
throw new CommandError("Failed to extract ZIP files into {$appDir}");
}
}
protected function deleteAppVendorFiles(string $appDir): void
{
$targetDir = $appDir . DIRECTORY_SEPARATOR . 'vendor';
if (!is_dir($targetDir)) {
return;
}
$it = new RecursiveDirectoryIterator($targetDir, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach($files as $file) {
if ($file->isDir()){
rmdir($file->getPathname());
} else {
unlink($file->getPathname());
}
}
$deleted = rmdir($targetDir);
if (!$deleted) {
throw new CommandError("Could not delete existing app vendor directory.");
}
}
protected function verifyZipChecksum(string $zipPath, string $targetChecksum): void
{
$zipChecksum = hash_file('sha256', $zipPath);
if ($zipChecksum !== $targetChecksum) {
unlink($zipPath);
throw new CommandError("Checksum of downloaded ZIP does not match the expected checksum.");
}
}
protected function downloadVendorZip(string $version): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'bs-cli-vendor-zip');
$targetUrl = "https://files.bookstackapp.com/vendor/{$version}.zip";
$targetFile = @fopen($targetUrl, 'rb');
if ($targetFile === false) {
throw new CommandError("Failed to download ZIP file from $targetUrl");
}
file_put_contents($tempFile, $targetFile);
return $tempFile;
}
/**
* @throws CommandError
*/
protected function getTargetChecksum(string $appDir): string
{
$checksumFile = implode(DIRECTORY_SEPARATOR, [$appDir, 'dev', 'checksums', 'vendor']);
$checksum = '';
if (file_exists($checksumFile)) {
$checksum = trim(file_get_contents($checksumFile));
}
if (empty($checksum)) {
throw new CommandError("Could not find a vendor checksum for validation.");
}
return $checksum;
}
}