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; } }