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