]> BookStack Code Mirror - system-cli/blob - src/Commands/BackupCommand.php
Restructured repo to work as it's own project
[system-cli] / src / Commands / BackupCommand.php
1 <?php
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, null, "Skip adding a database dump to the backup");
25         $this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup");
26         $this->addOption('no-themes', null, null, "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)
53         $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
54         $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
55
56         if ($handleDatabase) {
57             $output->writeln("<info>Dumping the database via mysqldump...</info>");
58             $dumpTempFile = $this->createDatabaseDump($appDir);
59             $output->writeln("<info>Adding database dump to backup archive...</info>");
60             $zip->addFile($dumpTempFile, 'db.sql');
61         }
62
63         if ($handleUploads) {
64             $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
65             $this->addUploadFoldersToZip($zip, $appDir);
66         }
67
68         if ($handleThemes) {
69             $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
70             $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
71         }
72
73         // Close off our zip and move it to the required location
74         $zip->close();
75         // Delete our temporary DB dump file if exists. Must be done after zip close.
76         if ($dumpTempFile) {
77             unlink($dumpTempFile);
78         }
79         // Move the zip into the target location
80         rename($zipTempFile, $zipOutFile);
81
82         // Announce end
83         $output->writeln("<info>Backup finished.</info>");
84         $output->writeln("Output ZIP saved to: {$zipOutFile}");
85
86         return Command::SUCCESS;
87     }
88
89     /**
90      * Ensure the required PHP extensions are installed for this command.
91      * @throws CommandError
92      */
93     protected function ensureRequiredExtensionInstalled(): void
94     {
95         if (!extension_loaded('zip')) {
96             throw new CommandError('The "zip" PHP extension is required to run this command');
97         }
98     }
99
100     /**
101      * Build a full zip path from the given suggestion, which may be empty,
102      * a path to a folder, or a path to a file in relative or absolute form.
103      * @throws CommandError
104      */
105     protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
106     {
107         $zipDir = getcwd() ?: $appDir;
108         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
109
110         if ($suggestedOutPath) {
111             if (is_dir($suggestedOutPath)) {
112                 $zipDir = realpath($suggestedOutPath);
113             } else if (is_dir(dirname($suggestedOutPath))) {
114                 $zipDir = realpath(dirname($suggestedOutPath));
115                 $zipName = basename($suggestedOutPath);
116             } else {
117                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
118             }
119         }
120
121         $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
122
123         if (file_exists($fullPath)) {
124             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
125         }
126
127         return $fullPath;
128     }
129
130     /**
131      * Add app-relative upload folders to the provided zip archive.
132      * Will recursively go through all directories to add all files.
133      */
134     protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
135     {
136         $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
137         $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
138     }
139
140     /**
141      * Recursively add all contents of the given dirPath to the provided zip file
142      * with a zip location of the targetZipPath.
143      */
144     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
145     {
146         $dirIter = new RecursiveDirectoryIterator($dirPath);
147         $fileIter = new \RecursiveIteratorIterator($dirIter);
148         /** @var SplFileInfo $file */
149         foreach ($fileIter as $file) {
150             if (!$file->isDir()) {
151                 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
152             }
153         }
154     }
155
156     /**
157      * Create a database dump and return the path to the dumped SQL output.
158      * @throws CommandError
159      */
160     protected function createDatabaseDump(string $appDir): string
161     {
162         $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
163         $mysql = MySqlRunner::fromEnvOptions($envOptions);
164         $mysql->ensureOptionsSet();
165
166         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
167         try {
168             $mysql->runDumpToFile($dumpTempFile);
169         } catch (\Exception $exception) {
170             unlink($dumpTempFile);
171             throw new CommandError($exception->getMessage());
172         }
173
174         return $dumpTempFile;
175     }
176 }