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 | ||
| 16 | class 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 | } |