Lines 19.87% 31 / 156
Methods 0.00% 0 / 6
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 latest 0.00% 0 / 53 0.00% 0 / 1 90
 rollback 0.00% 0 / 19 0.00% 0 / 1 42
 backup 0.00% 0 / 15 0.00% 0 / 1 30
 addDirectoryToZip 0.00% 0 / 13 0.00% 0 / 1 72
 extract 75.60% 31 / 41 0.00% 0 / 1 30.68
 deleteDirectory 0.00% 0 / 15 0.00% 0 / 1 90
16class DeploymentController
17{
18    public static string $command_latest   = "deploy latest";
19    public static string $command_rollback = "deploy rollback";
20
21    private array $excludeFromBackup = [
22        '.env',
23        'vendor',
24        'storage/backups',
25    ];
26
27    private array $excludeFromExtract = [
28        '.env',
29        'vendor',
30        'storage',
31    ];
32
33    private array $excludeFromDelete = [
34        '.env',
35        'vendor',
36        'storage/backups',
37        'storage/temp',
38        'logs',
39    ];
40
41    public function latest(): string
42    {
43        $url   = App::env('DEPLOY_URL');
44        $token = App::env('DEPLOY_TOKEN');
45
46        if (!$url) {
47            return "No DEPLOY_URL set in .env" . PHP_EOL;
48        }
49
50        echo "Deploy URL: {$url}" . PHP_EOL;
51        echo "Token: " . ($token ? substr($token, 0, 8) . "..." : "not set") . PHP_EOL;
52
53        $headers = [];
54        if ($token) {
55            $headers = [
56                'Authorization'        => "Bearer {$token}",
57                'Accept'               => 'application/vnd.github+json',
58                'X-GitHub-Api-Version' => '2022-11-28',
59            ];
60        }
61
62        echo "Downloading update..." . PHP_EOL;
63
64        $zipName = 'project-update.zip';
65        $zipPath = FileSystem::rootPath() . '/storage/downloads/' . $zipName;
66
67        $estimatedSize = 10 * 1024 * 1024;
68        $progress = new ProgressBar($estimatedSize);
69        $progress->setFormat('[{bar}] {percent}% - {eta} remaining');
70        $progress->setBarCharacters(['█', '░']);
71        $progress->setUpdateInterval(0.1);
72
73        $client = new Client([
74            'timeout'     => 120,
75            'curl_options'=> [
76                // CURLOPT_UNRESTRICTED_AUTH is deliberately NOT set: with it
77                // unset, libcurl strips the Authorization header on
78                // cross-host redirects, so the deploy token is not leaked to
79                // a redirect target.
80                CURLOPT_FOLLOWLOCATION    => true,
81            ],
82        ]);
83
84        try {
85            $response = $client->get($url, [], [
86                'headers'  => $headers,
87                'sink'     => $zipPath,
88                'progress' => function ($downloaded, $total) use ($progress) {
89                    $progress->update($downloaded);
90                },
91            ]);
92        } catch (ClientExceptionInterface $e) {
93            return ExceptionChain::render($e, "Failed to download update: ") . PHP_EOL;
94        }
95
96        $progress->finish();
97
98        if ($response->getStatusCode() !== 200) {
99            return "Failed to download update. HTTP status: " . $response->getStatusCode() . PHP_EOL;
100        }
101
102        if (!file_exists($zipPath)) {
103            return "Download appeared successful but zip not found at: {$zipPath}" . PHP_EOL;
104        }
105
106        echo "Downloaded " . round(filesize($zipPath) / 1024) . "KB" . PHP_EOL;
107
108        echo "Backing up current project..." . PHP_EOL;
109
110        $backupResult = $this->backup();
111        if ($backupResult !== true) {
112            return $backupResult;
113        }
114
115        echo "Applying update..." . PHP_EOL;
116
117        $extractResult = $this->extract($zipPath, $this->excludeFromExtract);
118        if ($extractResult !== true) {
119            return $extractResult;
120        }
121
122        unlink($zipPath);
123
124        return "Deployed successfully! 🎉" . PHP_EOL .
125            "Run 'php cli deploy rollback' to revert if needed." . PHP_EOL;
126    }
127
128    public function rollback(): string
129    {
130        $backupsDir = FileSystem::rootPath() . '/storage/backups/';
131
132        if (!is_dir($backupsDir)) {
133            return "No backups found." . PHP_EOL;
134        }
135
136        $backups = array_filter(
137            scandir($backupsDir),
138            fn($d) => $d !== '.' && $d !== '..' && str_ends_with($d, '.zip')
139        );
140
141        if (empty($backups)) {
142            return "No backups found." . PHP_EOL;
143        }
144
145        rsort($backups);
146        $latestZip = $backupsDir . $backups[0];
147
148        echo "Cleaning current project..." . PHP_EOL;
149
150        $this->deleteDirectory(FileSystem::rootPath(), $this->excludeFromDelete);
151
152        echo "Restoring from " . $backups[0] . "..." . PHP_EOL;
153
154        $extractResult = $this->extract($latestZip, $this->excludeFromExtract);
155        if ($extractResult !== true) {
156            return $extractResult;
157        }
158
159        unlink($latestZip);
160
161        return "Rolled back successfully! 🔙" . PHP_EOL;
162    }
163
164    private function backup(): true|string
165    {
166        $backupsDir = FileSystem::rootPath() . '/storage/backups/';
167        $timestamp  = date('YmdHis');
168        $backupPath = $backupsDir . $timestamp . '.zip';
169        $root       = FileSystem::rootPath();
170
171        if (!is_dir($backupsDir) && !mkdir($backupsDir, 0755, true)) {
172            return "Failed to create backups directory." . PHP_EOL;
173        }
174
175        $zip = new ZipArchive();
176
177        if ($zip->open($backupPath, ZipArchive::CREATE) !== true) {
178            return "Failed to create backup zip." . PHP_EOL;
179        }
180
181        try {
182            $this->addDirectoryToZip($zip, $root, $root, $this->excludeFromBackup);
183        } catch (Exception $e) {
184            $zip->close();
185            return ExceptionChain::render($e, "Backup failed: ") . PHP_EOL;
186        }
187
188        $zip->close();
189        return true;
190    }
191
192    private function addDirectoryToZip(ZipArchive $zip, string $src, string $root, array $exclude = []): void
193    {
194        $items = scandir($src);
195
196        foreach ($items as $item) {
197            if ($item === '.' || $item === '..') {
198                continue;
199            }
200
201            $srcPath  = $src . '/' . $item;
202            $relative = substr($srcPath, strlen($root) + 1);
203
204            foreach ($exclude as $ex) {
205                if ($relative === $ex || str_starts_with($relative, $ex . '/')) {
206                    continue 2;
207                }
208            }
209
210            if (is_dir($srcPath)) {
211                $zip->addEmptyDir($relative);
212                $this->addDirectoryToZip($zip, $srcPath, $root, $exclude);
213            } else {
214                $zip->addFile($srcPath, $relative);
215            }
216        }
217    }
218
219    private function extract(string $zipPath, array $exclude): true|string
220    {
221        $zip = new ZipArchive();
222
223        if ($zip->open($zipPath) !== true) {
224            return "Failed to open zip file." . PHP_EOL;
225        }
226
227        $root = FileSystem::rootPath();
228
229        // Detect leading folder prefix (e.g. GitHub zips: repo-main/)
230        $prefix = '';
231        $first  = $zip->getNameIndex(0);
232        if ($first && str_ends_with($first, '/')) {
233            $prefix = $first;
234        }
235
236        $realRoot = realpath($root);
237
238        for ($i = 0; $i < $zip->numFiles; $i++) {
239            $name = $zip->getNameIndex($i);
240
241            $relative = $prefix ? substr($name, strlen($prefix)) : $name;
242
243            if ($relative === '' || $relative === false) {
244                continue;
245            }
246
247            foreach ($exclude as $ex) {
248                if ($relative === $ex || str_starts_with($relative, $ex . '/') || str_starts_with($relative, $ex)) {
249                    continue 2;
250                }
251            }
252
253            // Zip-slip guard: reject any entry that escapes the project root.
254            // A crafted zip can name entries `../evil.php` or `../../etc/x`;
255            // without this check they would be written outside the root.
256            if (
257                str_contains($relative, '..')
258                || str_starts_with($relative, '/')
259                || str_contains($relative, '\\')
260                || preg_match('/^[a-zA-Z]:/', $relative)
261            ) {
262                $zip->close();
263                return "Refusing to extract entry with unsafe path: $relative" . PHP_EOL;
264            }
265
266            $target = $root . '/' . $relative;
267
268            // Create the parent directory (or the directory entry itself)
269            // before the containment check, so realpath() can resolve it.
270            if (str_ends_with($name, '/')) {
271                if (!is_dir($target)) {
272                    mkdir($target, 0755, true);
273                }
274            } else {
275                $dir = dirname($target);
276                if (!is_dir($dir)) {
277                    mkdir($dir, 0755, true);
278                }
279            }
280
281            // Resolve the target's parent and assert it stays inside the root.
282            // This is defense-in-depth on top of the lexical guard above: it
283            // catches symlinked parents that resolve outside the root.
284            $realTarget = realpath(dirname($target));
285            if (
286                $realRoot === false
287                || $realTarget === false
288                || !str_starts_with($realTarget, $realRoot . DIRECTORY_SEPARATOR)
289            ) {
290                $zip->close();
291                return "Refusing to extract entry outside project root: $relative" . PHP_EOL;
292            }
293
294            if (str_ends_with($name, '/')) {
295                continue;
296            }
297
298            file_put_contents($target, $zip->getFromIndex($i));
299        }
300
301        $zip->close();
302        return true;
303    }
304
305    private function deleteDirectory(string $dir, array $exclude = []): void
306    {
307        $items = scandir($dir);
308        $root  = FileSystem::rootPath();
309
310        foreach ($items as $item) {
311            if ($item === '.' || $item === '..') {
312                continue;
313            }
314
315            $path     = $dir . '/' . $item;
316            $relative = substr($path, strlen($root) + 1);
317
318            foreach ($exclude as $ex) {
319                if ($relative === $ex || str_starts_with($relative, $ex . '/')) {
320                    continue 2;
321                }
322            }
323
324            if (is_dir($path)) {
325                $this->deleteDirectory($path, $exclude);
326                if (count(scandir($path)) === 2) {
327                    rmdir($path);
328                }
329            } else {
330                unlink($path);
331            }
332        }
333    }
334}