]> BookStack Code Mirror - system-cli/blob - src/Commands/BackupCommand.php
a35ecb4985b82c1f9c2be61c033020a71bc8b67b
[system-cli] / src / Commands / BackupCommand.php
1 <?php declare(strict_types=1);
2
3 namespace Cli\Commands;
4
5 use Cli\Services\AppLocator;
6 use Cli\Services\EnvironmentLoader;
7 use Cli\Services\MySqlRunner;
8 use Cli\Services\Paths;
9 use RecursiveDirectoryIterator;
10 use SplFileInfo;
11 use Symfony\Component\Console\Command\Command;
12 use Symfony\Component\Console\Input\InputArgument;
13 use Symfony\Component\Console\Input\InputInterface;
14 use Symfony\Component\Console\Input\InputOption;
15 use Symfony\Component\Console\Output\OutputInterface;
16 use ZipArchive;
17
18 final class BackupCommand extends Command
19 {
20     protected function configure(): void
21     {
22         $this->setName('backup');
23         $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
24         $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
25         $this->addOption('no-database', null, InputOption::VALUE_NONE, "Skip adding a database dump to the backup");
26         $this->addOption('no-uploads', null, InputOption::VALUE_NONE, "Skip adding uploaded files to the backup");
27         $this->addOption('no-themes', null, InputOption::VALUE_NONE, "Skip adding the themes folder to the backup");
28         $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
29     }
30
31     /**
32      * @throws CommandError
33      */
34     protected function execute(InputInterface $input, OutputInterface $output): int
35     {
36         $appDir = AppLocator::require($input->getOption('app-directory'));
37         $output->writeln("<info>Checking system requirements...</info>");
38         $this->ensureRequiredExtensionInstalled();
39
40         $handleDatabase = !$input->getOption('no-database');
41         $handleUploads = !$input->getOption('no-uploads');
42         $handleThemes = !$input->getOption('no-themes');
43         $suggestedOutPath = $input->getArgument('backup-path');
44
45         $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
46
47         // Create a new ZIP file
48         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
49         $dumpTempFile = '';
50         $zip = new ZipArchive();
51         $zip->open($zipTempFile, ZipArchive::CREATE);
52
53         // Add default files (.env config file and this CLI if existing)
54         $zip->addFile(Paths::join($appDir, '.env'), '.env');
55         $cliPath = Paths::join($appDir, 'bookstack-system-cli');
56         if (file_exists($cliPath)) {
57             $zip->addFile($cliPath, 'bookstack-system-cli');
58         }
59
60         if ($handleDatabase) {
61             $output->writeln("<info>Dumping the database via mysqldump...</info>");
62             $dumpTempFile = $this->createDatabaseDump($appDir);
63             $output->writeln("<info>Adding database dump to backup archive...</info>");
64             $zip->addFile($dumpTempFile, 'db.sql');
65         }
66
67         if ($handleUploads) {
68             $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
69             $this->addUploadFoldersToZip($zip, $appDir);
70         }
71
72         if ($handleThemes) {
73             $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
74             $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
75         }
76
77         // Close off our zip and move it to the required location
78         $zip->close();
79         // Delete our temporary DB dump file if exists. Must be done after zip close.
80         if ($dumpTempFile) {
81             unlink($dumpTempFile);
82         }
83         // Move the zip into the target location
84         rename($zipTempFile, $zipOutFile);
85
86         // Announce end
87         $output->writeln("<info>Backup finished.</info>");
88         $output->writeln("Output ZIP saved to: {$zipOutFile}");
89
90         return Command::SUCCESS;
91     }
92
93     /**
94      * Ensure the required PHP extensions are installed for this command.
95      * @throws CommandError
96      */
97     protected function ensureRequiredExtensionInstalled(): void
98     {
99         if (!extension_loaded('zip')) {
100             throw new CommandError('The "zip" PHP extension is required to run this command');
101         }
102     }
103
104     /**
105      * Build a full zip path from the given suggestion, which may be empty,
106      * a path to a folder, or a path to a file in relative or absolute form.
107      * Targets the <app>/backups directory by default if existing, otherwise <app>.
108      * @throws CommandError
109      */
110     protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
111     {
112         $suggestedOutPath = Paths::resolve($suggestedOutPath);
113         $zipDir = Paths::join($appDir, 'backups');
114         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
115
116         if ($suggestedOutPath) {
117             if (is_dir($suggestedOutPath)) {
118                 $zipDir = realpath($suggestedOutPath);
119             } else if (is_dir(dirname($suggestedOutPath))) {
120                 $zipDir = realpath(dirname($suggestedOutPath));
121                 $zipName = basename($suggestedOutPath);
122             } else {
123                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
124             }
125         } else {
126             if (!is_dir($zipDir)) {
127                 $zipDir = $appDir;
128             }
129         }
130
131         $fullPath = Paths::join($zipDir, $zipName);
132
133         if (file_exists($fullPath)) {
134             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
135         } else if (!is_dir($zipDir)) {
136             throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
137         } else if (!is_writable($zipDir)) {
138             throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
139         }
140
141         return $fullPath;
142     }
143
144     /**
145      * Add app-relative upload folders to the provided zip archive.
146      * Will recursively go through all directories to add all files.
147      */
148     protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
149     {
150         $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
151         $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
152     }
153
154     /**
155      * Recursively add all contents of the given dirPath to the provided zip file
156      * with a zip location of the targetZipPath.
157      */
158     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
159     {
160         $dirIter = new RecursiveDirectoryIterator($dirPath);
161         $fileIter = new \RecursiveIteratorIterator($dirIter);
162         /** @var SplFileInfo $file */
163         foreach ($fileIter as $file) {
164             if (!$file->isDir()) {
165                 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
166             }
167         }
168     }
169
170     /**
171      * Create a database dump and return the path to the dumped SQL output.
172      * @throws CommandError
173      */
174     protected function createDatabaseDump(string $appDir): string
175     {
176         $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
177         $mysql = MySqlRunner::fromEnvOptions($envOptions);
178         $mysql->ensureOptionsSet();
179
180         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
181         try {
182             $mysql->runDumpToFile($dumpTempFile);
183         } catch (\Exception $exception) {
184             unlink($dumpTempFile);
185             throw new CommandError($exception->getMessage());
186         }
187
188         return $dumpTempFile;
189     }
190 }