$version, 'package_url' => $packageUrl, 'sha256' => $sha256, 'size' => $size, 'published_at' => $publishedAt, ]; } /** * Checks whether a newer release is available. * * If the installed version cannot be determined, available is true, so an * installation with an unreadable version file is not permanently blocked. * * @return array{current: string, latest: string, available: bool, manifest: array} */ function manageUpdateCheck(): array { $manifest = manageUpdateFetchManifest(); $current = manageClientVersion(); $available = $current === '' ? true : version_compare( manageVersionCompareValue($manifest['version']), manageVersionCompareValue($current), '>' ); return [ 'current' => $current, 'latest' => $manifest['version'], 'available' => $available, 'manifest' => $manifest, ]; } // --------------------------------------------------------------------------- // Download and extraction // --------------------------------------------------------------------------- function manageUpdateDownloadPackage(array $manifest, string $targetFile): void { manageEnsureDir(dirname($targetFile)); $version = (string) $manifest['version']; $response = manageClientRequest( 'GET', 'package.php?version=' . rawurlencode($version), null, 'application/json', (int) MANAGE_HTTP_TIMEOUT_LONG ); if ($response['status'] < 200 || $response['status'] >= 300) { throw new RuntimeException(manageClientErrorMessage($response['status'], $response['body'])); } if ($response['body'] === '') { throw new RuntimeException('Das heruntergeladene Paket ist leer.'); } if (file_put_contents($targetFile, $response['body'], LOCK_EX) === false) { throw new RuntimeException('Das heruntergeladene Paket konnte nicht gespeichert werden.'); } if ($manifest['size'] > 0 && filesize($targetFile) !== $manifest['size']) { unlink($targetFile); throw new RuntimeException('Die Größe des heruntergeladenen Pakets stimmt nicht überein.'); } $actualHash = strtolower(hash_file('sha256', $targetFile) ?: ''); if ($actualHash !== $manifest['sha256']) { unlink($targetFile); throw new RuntimeException('Die Prüfsumme des Pakets stimmt nicht überein.'); } } // Rejects zip-slip and anything else that would escape the stage directory. function manageUpdateValidateZipEntry(string $entry): bool { $entry = str_replace('\\', '/', $entry); $normalized = trim($entry, '/'); if ( $normalized === '' || str_contains($entry, "\0") || str_starts_with($entry, '/') || preg_match('/^[A-Za-z]:\//', $entry) === 1 ) { return false; } foreach (explode('/', $normalized) as $segment) { if ($segment === '' || $segment === '.' || $segment === '..') { return false; } } return true; } function manageUpdateExtractPackage(string $zipFile, string $stageDir): void { if (!class_exists('ZipArchive')) { throw new RuntimeException('Die PHP-Erweiterung ZipArchive ist nicht verfügbar.'); } manageRemoveDir($stageDir); manageEnsureDir($stageDir); $zip = new ZipArchive(); if ($zip->open($zipFile) !== true) { throw new RuntimeException('Das heruntergeladene Paket ist keine lesbare ZIP-Datei.'); } $sanityPaths = is_array(MANAGE_UPDATE_SANITY_PATHS) ? MANAGE_UPDATE_SANITY_PATHS : []; $hasAppFile = $sanityPaths === []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = (string) $zip->getNameIndex($i); if (!manageUpdateValidateZipEntry($name)) { $zip->close(); throw new RuntimeException('Das Paket enthält einen unsicheren Pfad: ' . $name); } foreach ($sanityPaths as $sanityPath) { $sanityPath = trim(str_replace('\\', '/', (string) $sanityPath), '/'); if ($sanityPath === '') { continue; } if ($name === $sanityPath || str_starts_with($name, $sanityPath . '/')) { $hasAppFile = true; } } } // Guards against rolling a completely unrelated ZIP over the shop. if (!$hasAppFile) { $zip->close(); throw new RuntimeException( 'Das Paket sieht nicht wie ein Release dieses Shops aus (erwartet: ' . implode(', ', array_map('strval', $sanityPaths)) . ').' ); } if (!$zip->extractTo($stageDir)) { $zip->close(); throw new RuntimeException('Das Paket konnte nicht entpackt werden.'); } $zip->close(); } // --------------------------------------------------------------------------- // Deployment // --------------------------------------------------------------------------- function manageUpdateRelativePath(string $path, string $baseDir): string { return ltrim(str_replace('\\', '/', substr($path, strlen($baseDir))), '/'); } /** * Whether a path from the package must be left alone. * * A configured entry ending in '/' protects the directory and everything below * it; anything else matches the exact path, but a directory named without the * slash still protects its contents. */ function manageUpdateShouldSkipPath(string $relativePath): bool { $relativePath = trim(str_replace('\\', '/', $relativePath), '/'); if ($relativePath === '') { return true; } $protected = is_array(MANAGE_UPDATE_PROTECTED_PATHS) ? MANAGE_UPDATE_PROTECTED_PATHS : []; foreach ($protected as $entry) { $entry = str_replace('\\', '/', (string) $entry); $entry = trim($entry, '/'); if ($entry === '') { continue; } if ($relativePath === $entry || str_starts_with($relativePath, $entry . '/')) { return true; } } return false; } function manageUpdateCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array { manageEnsureDir($backupDir); $copied = 0; $backedUp = 0; $skipped = 0; $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($items as $item) { $relativePath = manageUpdateRelativePath($item->getPathname(), $stageDir); if (manageUpdateShouldSkipPath($relativePath)) { $skipped++; continue; } $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath; if ($item->isDir()) { manageEnsureDir($targetPath); continue; } manageEnsureDir(dirname($targetPath)); if (file_exists($targetPath)) { $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath; manageEnsureDir(dirname($backupPath)); if (!copy($targetPath, $backupPath)) { throw new RuntimeException('Die Datei konnte nicht gesichert werden: ' . $relativePath); } $backedUp++; } if (!copy($item->getPathname(), $targetPath)) { throw new RuntimeException('Die Datei konnte nicht ausgerollt werden: ' . $relativePath); } @chmod($targetPath, fileperms($item->getPathname()) & 0777); $copied++; } return ['copied' => $copied, 'backed_up' => $backedUp, 'skipped' => $skipped]; } // Keeps only the aside copies of the run that just finished. function manageUpdateCleanupOldBackups(string $keepBackupDir): int { $backupRoot = rtrim(manageUpdateBackupRoot(), '/\\'); if (!is_dir($backupRoot)) { return 0; } $keepRealPath = realpath($keepBackupDir); $backupRootRealPath = realpath($backupRoot); if ($keepRealPath === false || $backupRootRealPath === false) { return 0; } $removed = 0; foreach (new DirectoryIterator($backupRootRealPath) as $item) { if ($item->isDot() || !$item->isDir()) { continue; } $path = $item->getPathname(); if (realpath($path) === $keepRealPath) { continue; } manageRemoveDir($path); if (is_dir($path)) { throw new RuntimeException('Ein altes Sicherungsverzeichnis konnte nicht entfernt werden: ' . $path); } $removed++; } return $removed; } /** * Downloads, verifies and deploys one release, then runs the post-update step. * * $options: * force bool redeploy even when no newer version is available * skip_hook bool deploy files only, run neither migrations nor the callback * * Throws if deployment fails. If only the post-update step fails, the function * returns normally with hook['success'] false — the files are live by then, and * the caller has to tell the two cases apart. */ function manageUpdateApply(array $options = []): array { $force = !empty($options['force']); $skipHook = !empty($options['skip_hook']); $appRoot = manageClientAppRoot(); $check = manageUpdateCheck(); $manifest = $check['manifest']; if (!$check['available'] && !$force) { throw new RuntimeException( 'Es ist kein neueres Update verfügbar. Mit "erneut ausrollen" kann dasselbe Paket noch einmal ' . 'ausgerollt werden.' ); } $runId = date('Ymd-His'); $workDir = manageUpdateWorkDir() . $runId; $stageDir = $workDir . DIRECTORY_SEPARATOR . 'stage'; $zipFile = $workDir . DIRECTORY_SEPARATOR . 'package.zip'; $backupDir = manageUpdateBackupRoot() . $runId . '-' . $manifest['version']; manageEnsureDir($workDir); try { manageUpdateDownloadPackage($manifest, $zipFile); manageUpdateExtractPackage($zipFile, $stageDir); $result = manageUpdateCopyWithBackup($stageDir, $appRoot, $backupDir); } finally { manageRemoveDir($workDir); } $removedBackups = manageUpdateCleanupOldBackups($backupDir); manageClientLog('INFO', 'Update deployed', [ 'from_version' => $check['current'], 'to_version' => $manifest['version'], 'copied' => $result['copied'], 'backed_up' => $result['backed_up'], 'backup_dir' => $backupDir, ]); $report = [ 'deployed' => true, 'from_version' => $check['current'], 'to_version' => $manifest['version'], 'copied' => $result['copied'], 'backed_up' => $result['backed_up'], 'skipped' => $result['skipped'], 'removed_backups' => $removedBackups, 'backup_dir' => $backupDir, 'hook' => null, ]; if ($skipHook) { $report['hook'] = [ 'success' => true, 'skipped' => true, 'migrations' => ['applied' => [], 'pending' => count(manageUpdatePendingMigrations())], ]; return $report; } // APP_VERSION is already loaded in this process from the old code, so // to_version comes from the manifest rather than from a re-read. $report['hook'] = manageUpdateRunPostHook([ 'from_version' => $check['current'], 'to_version' => $manifest['version'], 'backup_dir' => $backupDir, 'run_id' => $runId, ]); return $report; } // --------------------------------------------------------------------------- // Migrations // --------------------------------------------------------------------------- function manageMigrationsEnabled(): bool { $dir = MANAGE_MIGRATIONS_DIR; return is_string($dir) && trim($dir) !== ''; } function manageMigrationsDir(): string { return rtrim((string) MANAGE_MIGRATIONS_DIR, '/\\') . DIRECTORY_SEPARATOR; } function manageMigrationsReadState(): array { $state = manageReadJson((string) MANAGE_MIGRATIONS_STATE); $applied = isset($state['applied']) && is_array($state['applied']) ? $state['applied'] : []; return ['applied' => array_values($applied)]; } function manageMigrationsAppliedIds(): array { $ids = []; foreach (manageMigrationsReadState()['applied'] as $entry) { if (is_array($entry) && ($entry['id'] ?? '') !== '') { $ids[] = (string) $entry['id']; } } return $ids; } function manageMigrationsRecordApplied(string $id, int $durationMs): void { $state = manageMigrationsReadState(); $state['applied'][] = [ 'id' => $id, 'applied_at' => date(DATE_ATOM), 'version' => manageClientVersion(), 'duration_ms' => $durationMs, ]; manageWriteJson((string) MANAGE_MIGRATIONS_STATE, $state); } /** * All migration files in the installation, sorted by filename. * * The filename without .php is the migration id, so renaming an already applied * migration makes it run again. That is documented, not accidental. */ function manageMigrationsAvailable(): array { if (!manageMigrationsEnabled() || !is_dir(manageMigrationsDir())) { return []; } $migrations = []; foreach (glob(manageMigrationsDir() . '*.php') ?: [] as $path) { if (!is_file($path) || !is_readable($path)) { continue; } $id = basename($path, '.php'); if ($id === '' || $id[0] === '.') { continue; } $migrations[] = ['id' => $id, 'path' => $path]; } usort($migrations, function ($left, $right) { return strcmp($left['id'], $right['id']); }); return $migrations; } /** * Migrations that have not been applied yet, in execution order. */ function manageUpdatePendingMigrations(): array { $applied = manageMigrationsAppliedIds(); $pending = []; foreach (manageMigrationsAvailable() as $migration) { if (!in_array($migration['id'], $applied, true)) { $pending[] = $migration; } } return $pending; } // Builds the context handed to every migration and to the post-update hook. function manageHookContext(array $extra = []): array { return array_merge([ 'app_root' => manageClientAppRoot(), 'instance' => (string) MANAGE_INSTANCE, 'from_version' => '', 'to_version' => manageClientVersion(), 'backup_dir' => '', 'run_id' => '', ], $extra); } /** * Loads one migration file and returns its callable. * * Two supported shapes: * return function (array $context): void { ... }; * function up(array $context): void { ... } // defined in the file */ function manageMigrationResolveCallable(array $migration): callable { $returned = require $migration['path']; if (is_callable($returned)) { return $returned; } if (function_exists('up')) { return 'up'; } throw new RuntimeException( 'Die Migration ' . $migration['id'] . ' liefert keine Funktion zurück und definiert kein up().' ); } /** * Runs all pending migrations in order. * * Stops at the first failure; later migrations stay pending. Returns a report * rather than throwing, so a caller can distinguish "deployment succeeded but a * migration failed" from "deployment failed". * * @return array{success: bool, applied: array, failed: string|null, error: string|null, pending: int} */ function manageUpdateRunMigrations(array $context = []): array { $report = [ 'success' => true, 'applied' => [], 'failed' => null, 'error' => null, 'pending' => 0, ]; $pending = manageUpdatePendingMigrations(); if ($pending === []) { return $report; } $baseContext = manageHookContext($context); foreach ($pending as $position => $migration) { $startedAt = microtime(true); try { // A file that defines up() twice across two migrations would // collide, which is why the "return a closure" form is the // documented default. $callable = manageMigrationResolveCallable($migration); $callable(array_merge($baseContext, ['migration_id' => $migration['id']])); } catch (Throwable $exception) { $report['success'] = false; $report['failed'] = $migration['id']; $report['error'] = $exception->getMessage(); $report['pending'] = count($pending) - $position; manageClientLog('ERROR', 'Migration failed', [ 'migration' => $migration['id'], 'error' => $exception->getMessage(), ]); return $report; } $durationMs = (int) round((microtime(true) - $startedAt) * 1000); manageMigrationsRecordApplied($migration['id'], $durationMs); $report['applied'][] = $migration['id']; manageClientLog('INFO', 'Migration applied', [ 'migration' => $migration['id'], 'duration_ms' => $durationMs, ]); } return $report; } /** * Runs the configured project callback. * * @return array{configured: bool, success: bool, error: string|null} */ function manageUpdateRunPostHookCallback(array $context = []): array { $hook = MANAGE_UPDATE_POST_HOOK; if (!is_array($hook) || ($hook['callback'] ?? null) === null) { return ['configured' => false, 'success' => true, 'error' => null]; } try { $file = trim((string) ($hook['file'] ?? '')); if ($file !== '') { if (!is_file($file)) { throw new RuntimeException('Die Hook-Datei wurde nicht gefunden: ' . $file); } require_once $file; } $callback = $hook['callback']; if (!is_callable($callback)) { throw new RuntimeException( 'Der Hook-Callback ist nicht aufrufbar: ' . (is_string($callback) ? $callback : gettype($callback)) ); } $result = call_user_func($callback, manageHookContext($context)); if ($result === false || (is_array($result) && ($result['success'] ?? true) === false)) { $error = is_array($result) ? trim((string) ($result['error'] ?? '')) : ''; throw new RuntimeException( 'Der Post-Update-Hook meldet einen Fehler' . ($error !== '' ? ': ' . $error : '.') ); } } catch (Throwable $exception) { manageClientLog('ERROR', 'Post-update hook failed', [ 'error' => $exception->getMessage(), ]); return ['configured' => true, 'success' => false, 'error' => $exception->getMessage()]; } manageClientLog('INFO', 'Post-update hook finished', []); return ['configured' => true, 'success' => true, 'error' => null]; } /** * Full post-update step: migrations first, then the project callback. * * Migrations run first so the callback can rely on the new data shape. When a * migration fails the callback is skipped, because running it against a * half-migrated state is worse than not running it at all. * * @return array{success: bool, migrations: array, hook: array, error: string|null, failed_migration: string|null} */ function manageUpdateRunPostHook(array $context = []): array { $migrations = manageUpdateRunMigrations($context); if (!$migrations['success']) { return [ 'success' => false, 'migrations' => $migrations, 'hook' => ['configured' => false, 'success' => true, 'error' => null, 'skipped' => true], 'error' => $migrations['error'], 'failed_migration' => $migrations['failed'], ]; } $hook = manageUpdateRunPostHookCallback(array_merge($context, [ 'migrations' => $migrations['applied'], ])); return [ 'success' => $hook['success'], 'migrations' => $migrations, 'hook' => $hook, 'error' => $hook['error'], 'failed_migration' => null, ]; }