]> BookStack Code Mirror - system-cli/blob - scripts/Commands/BackupCommand.php
cf2ca35b190487bc2b297cf1cedb3ff5066262cb
[system-cli] / scripts / Commands / BackupCommand.php
1 <?php
2
3 namespace Cli\Commands;
4
5 use Minicli\Command\CommandCall;
6 use RecursiveDirectoryIterator;
7 use SplFileInfo;
8 use Symfony\Component\Process\ExecutableFinder;
9 use Symfony\Component\Process\Process;
10 use ZipArchive;
11
12 final class BackupCommand
13 {
14     public function __construct(
15         protected string $appDir
16     ) {
17     }
18
19     public function handle(CommandCall $input)
20     {
21         $handleDatabase = !$input->hasFlag('no-database');
22         $handleUploads = !$input->hasFlag('no-uploads');
23         $suggestedOutPath = $input->subcommand ?: '';
24
25         // TODO - Validate DB vars
26         // TODO - Backup themes directory, extra flag for no-themes
27         // TODO - Backup the running phar? For easier direct restore...
28         // TODO - Error handle each stage
29         // TODO - Validate zip (and any other extensions required) are active.
30
31         $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
32         $dumpTempFile = $this->createDatabaseDump();
33
34         // Create a new ZIP file
35         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
36         $zip = new ZipArchive();
37         $zip->open($zipTempFile, ZipArchive::CREATE);
38         $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
39
40         if ($handleDatabase) {
41             $zip->addFile($dumpTempFile, 'db.sql');
42         }
43
44         if ($handleUploads) {
45             $this->addUploadFoldersToZip($zip);
46         }
47
48         // Close off our zip and move it to the required location
49         $zip->close();
50         rename($zipTempFile, $zipOutFile);
51
52         // Delete our temporary DB dump file
53         unlink($dumpTempFile);
54
55         // Announce end and display errors
56         echo "Finished";
57     }
58
59     /**
60      * Build a full zip path from the given suggestion, which may be empty,
61      * a path to a folder, or a path to a file in relative or absolute form.
62      */
63     protected function buildZipFilePath(string $suggestedOutPath): string
64     {
65         $zipDir = getcwd() ?: $this->appDir;
66         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
67
68         if ($suggestedOutPath) {
69             if (is_dir($suggestedOutPath)) {
70                 $zipDir = realpath($suggestedOutPath);
71             } else if (is_dir(dirname($suggestedOutPath))) {
72                 $zipDir = realpath(dirname($suggestedOutPath));
73                 $zipName = basename($suggestedOutPath);
74             } else {
75                 // TODO - Handle not found output
76             }
77         }
78
79         $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
80
81         if (file_exists($fullPath)) {
82             // TODO
83         }
84
85         return $fullPath;
86     }
87
88     /**
89      * Add app-relative upload folders to the provided zip archive.
90      * Will recursively go through all directories to add all files.
91      */
92     protected function addUploadFoldersToZip(ZipArchive $zip): void
93     {
94         $fileDirs = [
95             $this->appDir . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads' => 'public/uploads',
96             $this->appDir . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'uploads' => 'storage/uploads',
97         ];
98
99         foreach ($fileDirs as $fullFileDir => $relativeFileDir) {
100             $dirIter = new RecursiveDirectoryIterator($fullFileDir);
101             $fileIter = new \RecursiveIteratorIterator($dirIter);
102             /** @var SplFileInfo $file */
103             foreach ($fileIter as $file) {
104                 if (!$file->isDir()) {
105                     $zip->addFile($file->getPathname(), $relativeFileDir . '/' . $fileIter->getSubPathname());
106                 }
107             }
108         }
109     }
110
111     /**
112      * Create a database dump and return the path to the dumped SQL output.
113      */
114     protected function createDatabaseDump(): string
115     {
116         $dbHost = ($_SERVER['DB_HOST'] ?? '');
117         $dbUser = ($_SERVER['DB_USERNAME'] ?? '');
118         $dbPass = ($_SERVER['DB_PASSWORD'] ?? '');
119         $dbDatabase = ($_SERVER['DB_DATABASE'] ?? '');
120
121         // Create a mysqldump for the BookStack database
122         $executableFinder = new ExecutableFinder();
123         $mysqldumpPath = $executableFinder->find('mysqldump');
124
125         $process = new Process([
126             $mysqldumpPath,
127             '-h', $dbHost,
128             '-u', $dbUser,
129             '-p' . $dbPass,
130             '--single-transaction',
131             '--no-tablespaces',
132             $dbDatabase,
133         ]);
134         $process->start();
135
136         $errors = "";
137         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
138         $dumpTempFileResource = fopen($dumpTempFile, 'w');
139         foreach ($process as $type => $data) {
140             if ($process::OUT === $type) {
141                 fwrite($dumpTempFileResource, $data);
142             } else { // $process::ERR === $type
143                 $errors .= $data . "\n";
144             }
145         }
146         fclose($dumpTempFileResource);
147
148         // TODO - Throw errors if existing
149         return $dumpTempFile;
150     }
151 }