| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703 |
- <?php
- // Update pipeline: check, download, verify, extract, deploy, post-update step.
- //
- // Deployment is an overlay copy: every file in the release package is written
- // over the shop root, with each overwritten file copied aside first. Three
- // consequences worth knowing before pressing the button:
- //
- // - files that disappeared between releases are NOT removed;
- // - there is no maintenance mode, the shop stays reachable while copying;
- // - there is no rollback, the aside copies are for manual recovery only.
- //
- // The post-update step runs migrations from migrations/ and then the callback
- // in MANAGE_UPDATE_POST_HOOK. A failure there is reported loudly rather than
- // silently, because the files are live at that point.
- declare(strict_types=1);
- function manageUpdateWorkDir(): string
- {
- return rtrim((string) MANAGE_WORK_DIR, '/\\') . DIRECTORY_SEPARATOR;
- }
- function manageUpdateBackupRoot(): string
- {
- return rtrim((string) MANAGE_UPDATE_BACKUP_DIR, '/\\') . DIRECTORY_SEPARATOR;
- }
- // ---------------------------------------------------------------------------
- // Manifest
- // ---------------------------------------------------------------------------
- /**
- * Fetches and strictly validates the manifest.
- *
- * Every field is re-checked here because the response decides which code the
- * shop will execute next.
- */
- function manageUpdateFetchManifest(): array
- {
- $decoded = manageClientRequestJson('GET', 'manifest.php', null, (int) MANAGE_HTTP_TIMEOUT);
- $version = trim((string) ($decoded['version'] ?? $decoded['latest'] ?? ''));
- $packageUrl = trim((string) ($decoded['package_url'] ?? ''));
- $sha256 = strtolower(trim((string) ($decoded['sha256'] ?? '')));
- $size = isset($decoded['size']) ? (int) $decoded['size'] : 0;
- $publishedAt = trim((string) ($decoded['published_at'] ?? ''));
- if (!manageIsVersionString($version)) {
- throw new RuntimeException('Die Version im Manifest ist ungültig.');
- }
- if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
- throw new RuntimeException('Die Paket-URL im Manifest ist ungültig.');
- }
- if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
- throw new RuntimeException('Die Prüfsumme im Manifest ist ungültig.');
- }
- return [
- 'version' => $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,
- ];
- }
|