]> BookStack Code Mirror - system-cli/blob - src/Commands/DownloadVendorCommand.php
DownloadVendorCommand: Added check for ZIP file access
[system-cli] / src / Commands / DownloadVendorCommand.php
1 <?php
2
3 namespace Cli\Commands;
4
5 use Cli\Services\AppLocator;
6 use Exception;
7 use RecursiveDirectoryIterator;
8 use RecursiveIteratorIterator;
9 use Symfony\Component\Console\Command\Command;
10 use Symfony\Component\Console\Input\InputInterface;
11 use Symfony\Component\Console\Input\InputOption;
12 use Symfony\Component\Console\Output\OutputInterface;
13 use ZipArchive;
14
15 class DownloadVendorCommand extends Command
16 {
17     protected function configure(): void
18     {
19         $this->setName('download-vendor');
20         $this->setDescription('Download and extract PHP vendor files in a BookStack instance.');
21         $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to download into', '');
22     }
23
24     /**
25      * @throws Exception
26      */
27     protected function execute(InputInterface $input, OutputInterface $output): int
28     {
29         $appDir = AppLocator::require($input->getOption('app-directory'));
30
31         $output->writeln("<info>Checking app version...</info>");
32         $version = AppLocator::getVersion($appDir);
33         if (empty($version)) {
34             throw new CommandError("Could not determine instance BookStack version.");
35         }
36         $targetChecksum = $this->getTargetChecksum($appDir);
37
38         $output->writeln("<info>Downloading ZIP from files.bookstackapp.com...</info>");
39         $zip = $this->downloadVendorZip($version);
40
41         $output->writeln("<info>Validating downloaded ZIP...</info>");
42         $this->verifyZipChecksum($zip, $targetChecksum);
43
44         $output->writeln("<info>Deleting existing vendor/ directory...</info>");
45         try {
46             $this->deleteAppVendorFiles($appDir);
47         } catch (Exception $exception) {
48             unlink($zip);
49             throw $exception;
50         }
51
52         $output->writeln("<info>Extracting ZIP into BookStack instance...</info>");
53         $this->extractZip($zip, $appDir);
54
55         $output->writeln("<info>Cleaning up old app services...</info>");
56         $cleaned = $this->cleanupAppServices($appDir);
57         if (!$cleaned) {
58             $output->writeln("<warning>Failed to remove exising app services file</warning>");
59         }
60
61         $output->writeln("<success>Successfully downloaded & extracted vendor files into BookStack instance!</success>");
62
63         return Command::SUCCESS;
64     }
65
66     protected function cleanupAppServices(string $appDir): bool
67     {
68         $servicesFile = implode(DIRECTORY_SEPARATOR, [$appDir, 'bootstrap', 'cache', 'services.php']);
69         if (file_exists($servicesFile)) {
70             return @unlink($servicesFile);
71         }
72
73         return true;
74     }
75
76     protected function extractZip(string $zipPath, string $appDir): void
77     {
78         $zip = new ZipArchive();
79         $opened = $zip->open($zipPath, ZipArchive::RDONLY);
80         $extracted = $zip->extractTo($appDir);
81         $closed = $zip->close();
82
83         unlink($zipPath);
84         if (!$opened || !$extracted || !$closed) {
85             throw new CommandError("Failed to extract ZIP files into {$appDir}");
86         }
87     }
88
89     protected function deleteAppVendorFiles(string $appDir): void
90     {
91         $targetDir = $appDir . DIRECTORY_SEPARATOR . 'vendor';
92         if (!is_dir($targetDir)) {
93             return;
94         }
95
96         $it = new RecursiveDirectoryIterator($targetDir, RecursiveDirectoryIterator::SKIP_DOTS);
97         $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
98         foreach($files as $file) {
99             if ($file->isDir()){
100                 rmdir($file->getPathname());
101             } else {
102                 unlink($file->getPathname());
103             }
104         }
105
106         $deleted = rmdir($targetDir);
107         if (!$deleted) {
108             throw new CommandError("Could not delete existing app vendor directory.");
109         }
110     }
111
112     protected function verifyZipChecksum(string $zipPath, string $targetChecksum): void
113     {
114         $zipChecksum = hash_file('sha256', $zipPath);
115         if ($zipChecksum !== $targetChecksum) {
116             unlink($zipPath);
117             throw new CommandError("Checksum of downloaded ZIP does not match the expected checksum.");
118         }
119     }
120
121     protected function downloadVendorZip(string $version): string
122     {
123         $tempFile = tempnam(sys_get_temp_dir(), 'bs-cli-vendor-zip');
124         $targetUrl = "https://files.bookstackapp.com/vendor/{$version}.zip";
125
126         $targetFile = @fopen($targetUrl, 'rb');
127         if ($targetFile === false) {
128             throw new CommandError("Failed to download ZIP file from $targetUrl");
129         }
130
131         file_put_contents($tempFile, $targetFile);
132
133         return $tempFile;
134     }
135
136     /**
137      * @throws CommandError
138      */
139     protected function getTargetChecksum(string $appDir): string
140     {
141         $checksumFile = implode(DIRECTORY_SEPARATOR, [$appDir, 'dev', 'checksums', 'vendor']);
142         $checksum = '';
143         if (file_exists($checksumFile)) {
144             $checksum = trim(file_get_contents($checksumFile));
145         }
146
147         if (empty($checksum)) {
148             throw new CommandError("Could not find a vendor checksum for validation.");
149         }
150
151         return $checksum;
152     }
153 }