dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxUpdateService.class.php
Go to the documentation of this file.
1<?php
2
3declare(strict_types=1);
4
5namespace dbx\dbxAdmin;
6
7use RuntimeException;
8use Throwable;
9use ZipArchive;
10
19{
20 private const PRODUCT = 'dbxapp';
21 private const CHANNEL = 'stable';
22 private const OWNER_REPOSITORY = 'ecb-dbxApp/dbxapp';
23 private const MAX_MANIFEST_BYTES = 1048576;
24 private const MAX_PACKAGE_BYTES = 268435456;
25 private const MAX_EXTRACTED_BYTES = 536870912;
26 private const MAX_PACKAGE_FILES = 20000;
27
28 private string $root;
29 private string $workDirectory;
30 private string $manifestUrl;
31 private int $cacheTtl;
32 private ?object $databaseAdapter;
33
34 public function __construct(
35 string $root = '',
36 string $manifestUrl = '',
37 int $cacheTtl = 21600,
38 ?object $databaseAdapter = null
39 ) {
40 $resolvedRoot = realpath($root !== '' ? $root : dirname(__DIR__, 4));
41 if ($resolvedRoot === false || !is_dir($resolvedRoot)) {
42 throw new RuntimeException('dbxApp-Projektwurzel wurde nicht gefunden.');
43 }
44
45 $this->root = rtrim($resolvedRoot, '\\/');
46 $this->workDirectory = $this->root . DIRECTORY_SEPARATOR
47 . 'files' . DIRECTORY_SEPARATOR . 'update';
48 $this->manifestUrl = $manifestUrl !== ''
49 ? $manifestUrl
50 : 'https://github.com/' . self::OWNER_REPOSITORY
51 . '/releases/latest/download/update.json';
52 $this->cacheTtl = max(300, $cacheTtl);
53 if ($databaseAdapter !== null) {
54 foreach (array('prepare', 'apply', 'rollback') as $method) {
55 if (!method_exists($databaseAdapter, $method)) {
56 throw new RuntimeException(
57 'DB-Update-Adapter besitzt die Methode nicht: ' . $method
58 );
59 }
60 }
61 }
62 $this->databaseAdapter = $databaseAdapter;
63 }
64
72 public static function configured(): self
73 {
74 $config = dbx()->get_config('dbxAdmin');
75 dbx()->get_include_obj('dbxDatabaseMigrationService', 'dbxAdmin');
76 dbx()->get_include_obj('dbxUpdateDatabaseAdapter', 'dbxAdmin');
77 $databaseAdapter = new dbxUpdateDatabaseAdapter();
78
79 return new self(
80 '',
81 is_array($config)
82 ? (string)($config['update_manifest_url'] ?? '')
83 : '',
84 is_array($config)
85 ? (int)($config['update_cache_ttl'] ?? 21600)
86 : 21600,
87 $databaseAdapter
88 );
89 }
90
99 public function status(): array
100 {
101 $cached = $this->readJson($this->workDirectory . DIRECTORY_SEPARATOR . 'check.json');
102 $staged = $this->readJson($this->workDirectory . DIRECTORY_SEPARATOR . 'staged.json');
103 $installed = $this->readJson($this->workDirectory . DIRECTORY_SEPARATOR . 'installed.json');
104 $manifest = is_array($cached['manifest'] ?? null) ? $cached['manifest'] : array();
105 $current = $this->currentVersion();
106
107 return array(
108 'current_version' => $current,
109 'available_version' => (string)($manifest['version'] ?? ''),
110 'update_available' => isset($manifest['version'])
111 && version_compare((string)$manifest['version'], $current, '>'),
112 'checked_at' => (string)($cached['checked_at'] ?? ''),
113 'staged_version' => (string)($staged['manifest']['version'] ?? ''),
114 'staged_at' => (string)($staged['staged_at'] ?? ''),
115 'stop_available' => is_array($staged['manifest'] ?? null)
116 && (string)($staged['manifest']['version'] ?? '') !== '',
117 'rollback_available' => is_array($installed)
118 && empty($installed['rolled_back_at'])
119 && is_dir((string)($installed['backup_directory'] ?? '')),
120 'last_installed_version' => (string)($installed['to_version'] ?? ''),
121 'last_installed_at' => (string)($installed['installed_at'] ?? ''),
122 'release_url' => (string)($manifest['release_url'] ?? ''),
123 );
124 }
125
131 public function check(bool $force = false): array
132 {
133 $cacheFile = $this->workDirectory . DIRECTORY_SEPARATOR . 'check.json';
134 $cached = $this->readJson($cacheFile);
135 $checkedAt = strtotime((string)($cached['checked_at'] ?? '')) ?: 0;
136 if (!$force
137 && is_array($cached['manifest'] ?? null)
138 && $checkedAt >= time() - $this->cacheTtl) {
139 return $this->validateManifest($cached['manifest']);
140 }
141
142 $json = $this->downloadText($this->manifestUrl, self::MAX_MANIFEST_BYTES);
143 $decoded = json_decode($json, true);
144 if (!is_array($decoded)) {
145 throw new RuntimeException('Das Update-Manifest ist kein gültiges JSON-Dokument.');
146 }
147 $manifest = $this->validateManifest($decoded);
148 $this->writeJson($cacheFile, array(
149 'checked_at' => gmdate('c'),
150 'manifest' => $manifest,
151 ));
152 return $manifest;
153 }
154
166 public function prepare(): array
167 {
168 $manifest = $this->check(true);
169 if (!version_compare($manifest['version'], $this->currentVersion(), '>')) {
170 return array(
171 'manifest' => $manifest,
172 'staged' => false,
173 );
174 }
175
176 $state = $this->stage($manifest);
177 $state['staged'] = true;
178 return $state;
179 }
180
187 public function validateManifest(array $manifest): array
188 {
189 if ((int)($manifest['schema'] ?? 0) !== 1
190 || (string)($manifest['product'] ?? '') !== self::PRODUCT
191 || (string)($manifest['channel'] ?? '') !== self::CHANNEL) {
192 throw new RuntimeException('Das Update-Manifest gehört nicht zum stabilen dbxApp-Kanal.');
193 }
194
195 $version = trim((string)($manifest['version'] ?? ''));
196 if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
197 throw new RuntimeException('Das Update-Manifest enthält keine stabile Version.');
198 }
199
200 $hash = strtolower(trim((string)($manifest['sha256'] ?? '')));
201 if (!preg_match('/^[a-f0-9]{64}$/', $hash)) {
202 throw new RuntimeException('Das Update-Manifest enthält keine gültige SHA-256-Prüfsumme.');
203 }
204
205 $base = 'https://github.com/' . self::OWNER_REPOSITORY;
206 $expectedZip = $base . '/releases/download/v' . $version
207 . '/dbxapp-' . $version . '.zip';
208 $expectedRelease = $base . '/releases/tag/v' . $version;
209 if ((string)($manifest['zip_url'] ?? '') !== $expectedZip
210 || (string)($manifest['release_url'] ?? '') !== $expectedRelease) {
211 throw new RuntimeException('Die Release-URLs verlassen die feste dbxApp-GitHub-Vertrauensgrenze.');
212 }
213
214 $requires = is_array($manifest['requires'] ?? null)
215 ? $manifest['requires']
216 : array();
217 $phpRequirement = trim((string)($requires['php'] ?? '>=8.2'));
218 if (!preg_match('/^>=\d+\.\d+(?:\.\d+)?$/', $phpRequirement)) {
219 throw new RuntimeException('Die PHP-Anforderung im Manifest ist ungültig.');
220 }
221 $minimumPhp = substr($phpRequirement, 2);
222 if (version_compare(PHP_VERSION, $minimumPhp, '<')) {
223 throw new RuntimeException(
224 'Das Update benötigt PHP ' . $phpRequirement
225 . '; installiert ist PHP ' . PHP_VERSION . '.'
226 );
227 }
228
229 $extensions = is_array($requires['extensions'] ?? null)
230 ? $requires['extensions']
231 : array();
232 foreach ($extensions as $extension) {
233 $extension = strtolower(trim((string)$extension));
234 if ($extension === '' || !preg_match('/^[a-z0-9_-]+$/', $extension)) {
235 throw new RuntimeException('Das Manifest enthält eine ungültige PHP-Erweiterung.');
236 }
237 if (!extension_loaded($extension)) {
238 throw new RuntimeException('Für das Update fehlt die PHP-Erweiterung ' . $extension . '.');
239 }
240 }
241
242 $manifest['version'] = $version;
243 $manifest['sha256'] = $hash;
244 $manifest['requires'] = array(
245 'php' => $phpRequirement,
246 'extensions' => array_values($extensions),
247 );
248 return $manifest;
249 }
250
256 public function stage(array $manifest = array()): array
257 {
258 $manifest = $manifest !== array()
260 : $this->check(false);
261
262 return $this->synchronized(
263 fn(): array => $this->stagePackage($manifest)
264 );
265 }
266
271 private function stagePackage(array $manifest): array
272 {
273 if (!version_compare($manifest['version'], $this->currentVersion(), '>')) {
274 throw new RuntimeException('Es ist kein neueres stabiles Update verfügbar.');
275 }
276
277 $this->discardStagedState(false);
278 $downloads = $this->workDirectory . DIRECTORY_SEPARATOR . 'downloads';
279 $this->ensureDirectory($downloads);
280 $zipFile = $downloads . DIRECTORY_SEPARATOR
281 . 'dbxapp-' . $manifest['version'] . '.zip';
282 $temporary = $zipFile . '.part';
283 $stagingRoot = $this->workDirectory . DIRECTORY_SEPARATOR . 'staging';
284 $this->ensureDirectory($stagingRoot);
285 $staging = $stagingRoot . DIRECTORY_SEPARATOR . $manifest['version'];
286
287 try {
288 if (is_file($temporary) && !unlink($temporary)) {
289 throw new RuntimeException('Ein unvollständiger Update-Download konnte nicht entfernt werden.');
290 }
291 $this->downloadFile($manifest['zip_url'], $temporary, self::MAX_PACKAGE_BYTES);
292 $actualHash = hash_file('sha256', $temporary);
293 if (!is_string($actualHash)
294 || !hash_equals($manifest['sha256'], strtolower($actualHash))) {
295 throw new RuntimeException('Die SHA-256-Prüfsumme des Update-Pakets stimmt nicht.');
296 }
297 if (is_file($zipFile) && !unlink($zipFile)) {
298 throw new RuntimeException('Ein altes Update-Paket konnte nicht ersetzt werden.');
299 }
300 if (!rename($temporary, $zipFile)) {
301 throw new RuntimeException('Das geprüfte Update-Paket konnte nicht bereitgestellt werden.');
302 }
303
304 if (is_dir($staging)) {
305 $this->removeTree($staging, $stagingRoot);
306 }
307 $this->ensureDirectory($staging);
308
310 $zip = new ZipArchive();
311 if ($zip->open($zipFile) !== true) {
312 throw new RuntimeException('Das Update-Paket konnte nicht isoliert geöffnet werden.');
313 }
314 try {
315 if (!$zip->extractTo($staging)) {
316 throw new RuntimeException('Das Update-Paket konnte nicht isoliert entpackt werden.');
317 }
318 } finally {
319 $zip->close();
320 }
321 $this->verifyExtractedFiles($staging, $package['hashes']);
322
323 $state = array(
324 'schema' => 1,
325 'staged_at' => gmdate('c'),
326 'zip_file' => $zipFile,
327 'staging_directory' => $staging,
328 'manifest' => $manifest,
329 'files' => $package['files'],
330 );
331 $this->writeJson(
332 $this->workDirectory . DIRECTORY_SEPARATOR . 'staged.json',
333 $state
334 );
335 return $state;
336 } catch (Throwable $exception) {
337 @unlink($temporary);
338 @unlink($zipFile);
339 if (is_dir($staging)) {
340 try {
341 $this->removeTree($staging, $stagingRoot);
342 } catch (Throwable) {
343 }
344 }
345 throw $exception;
346 }
347 }
348
356 public function inspectPackage(string $zipFile, array $manifest): array
357 {
358 if (!class_exists(ZipArchive::class)) {
359 throw new RuntimeException('Für Updates wird die PHP-Erweiterung zip benötigt.');
360 }
361 if (!is_file($zipFile)) {
362 throw new RuntimeException('Das Update-Paket wurde nicht gefunden.');
363 }
364 $manifest = $this->validateManifest($manifest);
365
366 $zip = new ZipArchive();
367 if ($zip->open($zipFile) !== true) {
368 throw new RuntimeException('Das Update-Paket ist kein lesbares ZIP-Archiv.');
369 }
370
371 try {
372 if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_PACKAGE_FILES) {
373 throw new RuntimeException('Das Update-Paket enthält eine unzulässige Dateianzahl.');
374 }
375
376 $files = array();
377 $caseMap = array();
378 $totalSize = 0;
379 for ($index = 0; $index < $zip->numFiles; $index++) {
380 $stat = $zip->statIndex($index);
381 if (!is_array($stat)) {
382 throw new RuntimeException('Ein ZIP-Eintrag konnte nicht geprüft werden.');
383 }
384 $raw = (string)($stat['name'] ?? '');
385 $isDirectory = str_ends_with($raw, '/');
386 $relative = $this->normalizeArchivePath($raw);
387 if ($relative === '') {
388 if ($isDirectory) {
389 continue;
390 }
391 throw new RuntimeException('Das Update-Paket enthält einen leeren Dateinamen.');
392 }
393
394 $attributes = 0;
395 $operations = 0;
396 if ($zip->getExternalAttributesIndex($index, $operations, $attributes)
397 && (($attributes >> 16) & 0170000) === 0120000) {
398 throw new RuntimeException('Symbolische Links sind in Update-Paketen nicht erlaubt.');
399 }
400 if ($isDirectory) {
401 continue;
402 }
403 if ($this->isProtectedPath($relative)) {
404 throw new RuntimeException('Das Update-Paket enthält lokale Laufzeitdaten: ' . $relative);
405 }
406
407 $caseKey = strtolower($relative);
408 if (isset($caseMap[$caseKey])) {
409 throw new RuntimeException('Das Update-Paket enthält kollidierende Dateinamen.');
410 }
411 $caseMap[$caseKey] = true;
412 $totalSize += max(0, (int)($stat['size'] ?? 0));
413 if ($totalSize > self::MAX_EXTRACTED_BYTES) {
414 throw new RuntimeException('Das entpackte Update-Paket ist zu groß.');
415 }
416 $files[] = $relative;
417 }
418
419 foreach (array('VERSION', 'index.php', 'dbx/include/dbxApi.php', '.dbx-release-files.json') as $required) {
420 if (!in_array($required, $files, true)) {
421 throw new RuntimeException('Im Update-Paket fehlt ' . $required . '.');
422 }
423 }
424
425 $version = trim((string)$zip->getFromName('VERSION'));
426 if ($version !== $manifest['version']) {
427 throw new RuntimeException('VERSION im Update-Paket stimmt nicht mit dem Manifest überein.');
428 }
429
430 $inventoryJson = $zip->getFromName('.dbx-release-files.json');
431 $inventory = json_decode((string)$inventoryJson, true);
432 if (!is_array($inventory)
433 || (int)($inventory['schema'] ?? 0) !== 1
434 || (string)($inventory['product'] ?? '') !== self::PRODUCT
435 || (string)($inventory['version'] ?? '') !== $manifest['version']
436 || !is_array($inventory['files'] ?? null)) {
437 throw new RuntimeException('Das Update-Paket enthält kein gültiges Datei-Inventar.');
438 }
439
440 $hashes = array();
441 foreach ($inventory['files'] as $relative => $expectedHash) {
442 $relative = $this->normalizeArchivePath((string)$relative);
443 if ($relative === '.dbx-release-files.json') {
444 continue;
445 }
446 if (!in_array($relative, $files, true)
447 || !is_string($expectedHash)
448 || !preg_match('/^[a-f0-9]{64}$/', $expectedHash)) {
449 throw new RuntimeException('Das Datei-Inventar ist unvollständig oder ungültig.');
450 }
451 $content = $zip->getFromName($relative);
452 if (!is_string($content)
453 || !hash_equals($expectedHash, hash('sha256', $content))) {
454 throw new RuntimeException('Dateiprüfsumme stimmt nicht: ' . $relative);
455 }
456 $hashes[$relative] = $expectedHash;
457 }
458
459 $inventoryPaths = array_keys($inventory['files']);
460 sort($inventoryPaths, SORT_STRING);
461 sort($files, SORT_STRING);
462 if ($inventoryPaths !== $files) {
463 throw new RuntimeException('ZIP-Inhalt und Datei-Inventar sind nicht identisch.');
464 }
465 return array('files' => $files, 'hashes' => $hashes);
466 } finally {
467 $zip->close();
468 }
469 }
470
476 public function install(): array
477 {
478 return $this->synchronized(
479 fn(): array => $this->installPackage()
480 );
481 }
482
486 private function installPackage(): array
487 {
488 $stateFile = $this->workDirectory . DIRECTORY_SEPARATOR . 'staged.json';
489 $state = $this->readJson($stateFile);
490 if (!is_array($state['manifest'] ?? null)
491 || !is_array($state['files'] ?? null)) {
492 throw new RuntimeException('Es ist kein geprüftes Update bereitgestellt.');
493 }
494
495 $manifest = $this->validateManifest($state['manifest']);
496 $zipFile = (string)($state['zip_file'] ?? '');
497 $staging = (string)($state['staging_directory'] ?? '');
498 if (!$this->isInside($zipFile, $this->workDirectory)
499 || !$this->isInside($staging, $this->workDirectory)
500 || !is_dir($staging)) {
501 throw new RuntimeException('Der Update-Stagingbereich ist ungültig.');
502 }
503 $actualHash = is_file($zipFile) ? hash_file('sha256', $zipFile) : false;
504 if (!is_string($actualHash)
505 || !hash_equals($manifest['sha256'], strtolower($actualHash))) {
506 throw new RuntimeException('Das bereitgestellte Update-Paket wurde verändert.');
507 }
508
510 $this->verifyExtractedFiles($staging, $package['hashes']);
511 $current = $this->currentVersion();
512 if (!version_compare($manifest['version'], $current, '>')) {
513 throw new RuntimeException('Das bereitgestellte Update ist nicht neuer als die Installation.');
514 }
515
516 $newFiles = $package['files'];
517 $oldFiles = $this->installedInventory();
518 $obsolete = array_values(array_diff($oldFiles, $newFiles));
519 $backupRoot = $this->workDirectory . DIRECTORY_SEPARATOR . 'backups';
520 $this->ensureDirectory($backupRoot);
521 $backup = $backupRoot . DIRECTORY_SEPARATOR
522 . preg_replace('/[^0-9A-Za-z._-]+/', '-', $current . '-to-' . $manifest['version'])
523 . '-' . gmdate('Ymd-His');
524 $backupFiles = $backup . DIRECTORY_SEPARATOR . 'files';
525 $this->ensureDirectory($backupFiles);
526
527 $entries = array();
528 foreach (array_values(array_unique(array_merge($newFiles, $obsolete))) as $relative) {
529 $relative = $this->normalizeArchivePath($relative);
530 if ($relative === '' || $this->isProtectedPath($relative)) {
531 continue;
532 }
533 $destination = $this->destination($relative);
534 $existed = is_file($destination);
535 if ($existed) {
536 $backupFile = $backupFiles . DIRECTORY_SEPARATOR
537 . str_replace('/', DIRECTORY_SEPARATOR, $relative);
538 $this->ensureDirectory(dirname($backupFile));
539 if (!copy($destination, $backupFile)) {
540 throw new RuntimeException('Sicherung fehlgeschlagen: ' . $relative);
541 }
542 }
543 $entries[] = array(
544 'path' => $relative,
545 'existed' => $existed,
546 'obsolete' => in_array($relative, $obsolete, true),
547 );
548 }
549
550 $backupState = array(
551 'schema' => 1,
552 'from_version' => $current,
553 'to_version' => $manifest['version'],
554 'created_at' => gmdate('c'),
555 'backup_directory' => $backup,
556 'entries' => $entries,
557 );
558 $databaseState = array();
559 if ($this->databaseAdapter !== null) {
560 $databaseState = $this->databaseAdapter->prepare(
561 $staging,
562 $manifest,
563 $backup
564 );
565 $backupState['database'] = $databaseState;
566 }
567 $this->writeJson($backup . DIRECTORY_SEPARATOR . 'backup.json', $backupState);
568
569 try {
570 foreach ($obsolete as $relative) {
571 $relative = $this->normalizeArchivePath($relative);
572 if ($relative === '' || $this->isProtectedPath($relative)) {
573 continue;
574 }
575 $destination = $this->destination($relative);
576 if (is_file($destination) && !unlink($destination)) {
577 throw new RuntimeException('Veraltete Datei konnte nicht entfernt werden: ' . $relative);
578 }
579 }
580
581 foreach ($newFiles as $relative) {
582 $relative = $this->normalizeArchivePath($relative);
583 $source = $staging . DIRECTORY_SEPARATOR
584 . str_replace('/', DIRECTORY_SEPARATOR, $relative);
585 $destination = $this->destination($relative);
586 if (!is_file($source)) {
587 throw new RuntimeException('Staging-Datei fehlt: ' . $relative);
588 }
589 $this->ensureDirectory(dirname($destination));
590 if (!copy($source, $destination)) {
591 throw new RuntimeException('Update-Datei konnte nicht installiert werden: ' . $relative);
592 }
593 }
594
595 if ($this->databaseAdapter !== null && $databaseState !== array()) {
596 $backupState['database_result'] = $this->databaseAdapter->apply(
597 $databaseState
598 );
599 $this->writeJson(
600 $backup . DIRECTORY_SEPARATOR . 'backup.json',
601 $backupState
602 );
603 }
604
605 if ($this->currentVersion() !== $manifest['version']) {
606 throw new RuntimeException('Die installierte VERSION konnte nicht bestätigt werden.');
607 }
608 } catch (Throwable $exception) {
609 $rollbackErrors = array();
610 if ($this->databaseAdapter !== null && $databaseState !== array()) {
611 try {
612 $this->databaseAdapter->rollback($databaseState);
613 } catch (Throwable $databaseRollbackError) {
614 $rollbackErrors[] = 'DB-Rollback: ' . $databaseRollbackError->getMessage();
615 }
616 }
617 try {
618 $this->restoreBackup($backupState);
619 } catch (Throwable $fileRollbackError) {
620 $rollbackErrors[] = 'Datei-Rollback: ' . $fileRollbackError->getMessage();
621 }
622 $rollbackSuffix = $rollbackErrors !== array()
623 ? ' Rollbackfehler: ' . implode('; ', $rollbackErrors)
624 : '';
625 throw new RuntimeException(
626 'Update fehlgeschlagen und wurde zurückgerollt: '
627 . $exception->getMessage() . $rollbackSuffix,
628 0,
629 $exception
630 );
631 }
632
633 $installed = $backupState + array('installed_at' => gmdate('c'));
634 $this->writeJson(
635 $this->workDirectory . DIRECTORY_SEPARATOR . 'installed.json',
637 );
638 try {
639 $this->discardStagedState(false);
640 } catch (Throwable $cleanupError) {
641 @unlink($this->workDirectory . DIRECTORY_SEPARATOR . 'staged.json');
642 $installed['cleanup_warning'] = $cleanupError->getMessage();
643 $this->writeJson(
644 $this->workDirectory . DIRECTORY_SEPARATOR . 'installed.json',
646 );
647 }
648 if (function_exists('opcache_reset')) {
649 @opcache_reset();
650 }
651 return $installed;
652 }
653
663 public function cancel(): array
664 {
665 return $this->synchronized(function (): array {
666 $state = $this->discardStagedState(true);
667 return array(
668 'version' => (string)($state['manifest']['version'] ?? ''),
669 'stopped_at' => gmdate('c'),
670 );
671 });
672 }
673
679 public function rollback(): array
680 {
681 return $this->synchronized(
682 fn(): array => $this->rollbackInstalledPackage()
683 );
684 }
685
689 private function rollbackInstalledPackage(): array
690 {
691 $stateFile = $this->workDirectory . DIRECTORY_SEPARATOR . 'installed.json';
692 $state = $this->readJson($stateFile);
693 if (!is_array($state['entries'] ?? null)
694 || !is_dir((string)($state['backup_directory'] ?? ''))
695 || !empty($state['rolled_back_at'])) {
696 throw new RuntimeException('Es ist keine rückrollbare Aktualisierung vorhanden.');
697 }
698 if ($this->currentVersion() !== (string)($state['to_version'] ?? '')) {
699 throw new RuntimeException('Die installierte Version wurde seit dem Update verändert.');
700 }
701
702 if ($this->databaseAdapter !== null
703 && is_array($state['database'] ?? null)
704 && $state['database'] !== array()
705 ) {
706 $this->databaseAdapter->rollback($state['database']);
707 }
708 $this->restoreBackup($state);
709 $state['rolled_back_at'] = gmdate('c');
710 $this->writeJson($stateFile, $state);
711 if (function_exists('opcache_reset')) {
712 @opcache_reset();
713 }
714 return $state;
715 }
716
723 private function discardStagedState(bool $required): array
724 {
725 $stateFile = $this->workDirectory . DIRECTORY_SEPARATOR . 'staged.json';
726 $state = $this->readJson($stateFile);
727 if (!is_array($state['manifest'] ?? null)) {
728 if ($required) {
729 throw new RuntimeException('Es ist kein vorbereitetes Update vorhanden.');
730 }
731 return array();
732 }
733
734 $staging = (string)($state['staging_directory'] ?? '');
735 $zipFile = (string)($state['zip_file'] ?? '');
736 foreach (array($staging, $zipFile) as $path) {
737 if ($path !== '' && !$this->isInside($path, $this->workDirectory)) {
738 throw new RuntimeException('Der vorbereitete Update-Status ist ungültig.');
739 }
740 }
741
742 if ($staging !== '' && is_dir($staging)) {
743 $this->removeTree($staging, $this->workDirectory);
744 }
745 foreach (array($zipFile, $zipFile !== '' ? $zipFile . '.part' : '') as $file) {
746 if ($file !== '' && is_file($file) && !unlink($file)) {
747 throw new RuntimeException('Eine vorbereitete Update-Datei konnte nicht entfernt werden.');
748 }
749 }
750 if (is_file($stateFile) && !unlink($stateFile)) {
751 throw new RuntimeException('Der vorbereitete Update-Status konnte nicht entfernt werden.');
752 }
753 return $state;
754 }
755
759 private function restoreBackup(array $state): void
760 {
761 $backup = (string)($state['backup_directory'] ?? '');
762 if (!$this->isInside($backup, $this->workDirectory)) {
763 throw new RuntimeException('Das Sicherungsverzeichnis liegt außerhalb des Updatebereichs.');
764 }
765 $entries = is_array($state['entries'] ?? null) ? $state['entries'] : array();
766 foreach (array_reverse($entries) as $entry) {
767 $relative = $this->normalizeArchivePath((string)($entry['path'] ?? ''));
768 if ($relative === '' || $this->isProtectedPath($relative)) {
769 continue;
770 }
771 $destination = $this->destination($relative);
772 if (!empty($entry['existed'])) {
773 $source = $backup . DIRECTORY_SEPARATOR . 'files'
774 . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
775 if (!is_file($source)) {
776 throw new RuntimeException('Sicherungsdatei fehlt: ' . $relative);
777 }
778 $this->ensureDirectory(dirname($destination));
779 if (!copy($source, $destination)) {
780 throw new RuntimeException('Sicherungsdatei konnte nicht wiederhergestellt werden: ' . $relative);
781 }
782 } elseif (is_file($destination) && !unlink($destination)) {
783 throw new RuntimeException('Neu installierte Datei konnte nicht entfernt werden: ' . $relative);
784 }
785 }
786 }
787
791 private function installedInventory(): array
792 {
793 $inventory = $this->readJson(
794 $this->root . DIRECTORY_SEPARATOR . '.dbx-release-files.json'
795 );
796 if ((int)($inventory['schema'] ?? 0) !== 1
797 || (string)($inventory['product'] ?? '') !== self::PRODUCT
798 || !is_array($inventory['files'] ?? null)) {
799 return array();
800 }
801
802 $files = array();
803 foreach (array_keys($inventory['files']) as $relative) {
804 try {
805 $relative = $this->normalizeArchivePath((string)$relative);
806 } catch (Throwable) {
807 continue;
808 }
809 if ($relative !== '' && !$this->isProtectedPath($relative)) {
810 $files[] = $relative;
811 }
812 }
813 return array_values(array_unique($files));
814 }
815
819 private function verifyExtractedFiles(string $staging, array $hashes): void
820 {
821 foreach ($hashes as $relative => $expected) {
822 $file = $staging . DIRECTORY_SEPARATOR
823 . str_replace('/', DIRECTORY_SEPARATOR, $relative);
824 $actual = is_file($file) ? hash_file('sha256', $file) : false;
825 if (!is_string($actual) || !hash_equals($expected, strtolower($actual))) {
826 throw new RuntimeException('Staging-Datei wurde verändert: ' . $relative);
827 }
828 }
829 }
830
831 private function currentVersion(): string
832 {
833 $versionFile = $this->root . DIRECTORY_SEPARATOR . 'VERSION';
834 $version = is_file($versionFile) ? trim((string)file_get_contents($versionFile)) : '';
835 if (!preg_match('/^\d+\.\d+\.\d+(?:-dev)?$/', $version)) {
836 throw new RuntimeException('Die lokal installierte VERSION ist ungültig.');
837 }
838 return $version;
839 }
840
841 private function destination(string $relative): string
842 {
843 $relative = $this->normalizeArchivePath($relative);
844 if ($relative === '' || $this->isProtectedPath($relative)) {
845 throw new RuntimeException('Ungültiger Installationspfad: ' . $relative);
846 }
847 return $this->root . DIRECTORY_SEPARATOR
848 . str_replace('/', DIRECTORY_SEPARATOR, $relative);
849 }
850
851 private function normalizeArchivePath(string $path): string
852 {
853 if (str_contains($path, "\0") || str_contains($path, '\\')) {
854 throw new RuntimeException('Das Update-Paket enthält einen unsicheren Pfad.');
855 }
856 $path = trim($path);
857 if ($path === '' || str_starts_with($path, '/') || preg_match('/^[A-Za-z]:/', $path)) {
858 return '';
859 }
860 $parts = explode('/', rtrim($path, '/'));
861 foreach ($parts as $part) {
862 if ($part === '' || $part === '.' || $part === '..') {
863 throw new RuntimeException('Das Update-Paket enthält Pfad-Traversal.');
864 }
865 }
866 return implode('/', $parts);
867 }
868
869 private function isProtectedPath(string $relative): bool
870 {
871 $relative = ltrim(str_replace('\\', '/', $relative), '/');
872 $base = basename($relative);
873 $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
874 if ($base === '.gitkeep') {
875 return false;
876 }
877 return $relative === '.env'
878 || $relative === '.env.local'
879 || str_starts_with($relative, '.git/')
880 || str_starts_with($relative, 'files/')
881 || str_starts_with($relative, 'dbx/files/')
882 || preg_match('#/db/#i', '/' . $relative)
883 || preg_match('#/(?:cache|tmp|work|backup|backups|_backup|\.backup|uploads)/#i', '/' . $relative)
884 || in_array($base, array('config.local.php'), true)
885 || in_array($extension, array(
886 'db3', 'sqlite', 'sqlite3', 'log', 'tmp', 'bak', 'backup',
887 'pem', 'key', 'p12', 'pfx',
888 ), true);
889 }
890
891 private function downloadText(string $url, int $maximumBytes): string
892 {
893 $content = '';
894 $this->curlRequest($url, static function ($handle, string $chunk) use (&$content, $maximumBytes): int {
895 if (strlen($content) + strlen($chunk) > $maximumBytes) {
896 return 0;
897 }
898 $content .= $chunk;
899 return strlen($chunk);
900 });
901 return $content;
902 }
903
904 private function downloadFile(string $url, string $target, int $maximumBytes): void
905 {
906 $directory = dirname($target);
907 $this->ensureDirectory($directory);
908 $stream = fopen($target, 'wb');
909 if (!is_resource($stream)) {
910 throw new RuntimeException('Die Update-Download-Datei konnte nicht geöffnet werden.');
911 }
912 $written = 0;
913 try {
914 $this->curlRequest($url, static function ($handle, string $chunk) use ($stream, &$written, $maximumBytes): int {
915 $length = strlen($chunk);
916 if ($written + $length > $maximumBytes) {
917 return 0;
918 }
919 $result = fwrite($stream, $chunk);
920 if ($result !== $length) {
921 return 0;
922 }
923 $written += $length;
924 return $length;
925 });
926 } finally {
927 fclose($stream);
928 }
929 }
930
934 private function curlRequest(string $url, callable $writer): void
935 {
936 if (!extension_loaded('curl')) {
937 throw new RuntimeException('Für die Update-Prüfung wird die PHP-Erweiterung curl benötigt.');
938 }
939 $this->assertTrustedUrl($url, false);
940 $curl = curl_init($url);
941 if ($curl === false) {
942 throw new RuntimeException('Die HTTPS-Verbindung konnte nicht initialisiert werden.');
943 }
944
945 $options = array(
946 CURLOPT_FOLLOWLOCATION => true,
947 CURLOPT_MAXREDIRS => 5,
948 CURLOPT_CONNECTTIMEOUT => 15,
949 CURLOPT_TIMEOUT => 180,
950 CURLOPT_USERAGENT => 'dbxApp-Updater/' . $this->currentVersion(),
951 CURLOPT_SSL_VERIFYPEER => true,
952 CURLOPT_SSL_VERIFYHOST => 2,
953 CURLOPT_FAILONERROR => false,
954 CURLOPT_WRITEFUNCTION => $writer,
955 );
956 if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTPS')) {
957 $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTPS;
958 }
959 if (defined('CURLOPT_REDIR_PROTOCOLS') && defined('CURLPROTO_HTTPS')) {
960 $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTPS;
961 }
962 curl_setopt_array($curl, $options);
963 $ok = curl_exec($curl);
964 $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
965 $effective = (string)curl_getinfo($curl, CURLINFO_EFFECTIVE_URL);
966 $error = curl_error($curl);
967 curl_close($curl);
968
969 if ($ok !== true || $status < 200 || $status >= 300) {
970 throw new RuntimeException(
971 'Update-Download fehlgeschlagen'
972 . ($status > 0 ? ' (HTTP ' . $status . ')' : '')
973 . ($error !== '' ? ': ' . $error : '.'),
974 $status
975 );
976 }
977 $this->assertTrustedUrl($effective, true);
978 }
979
980 private function assertTrustedUrl(string $url, bool $allowAssetHost): void
981 {
982 $parts = parse_url($url);
983 $scheme = strtolower((string)($parts['scheme'] ?? ''));
984 $host = strtolower((string)($parts['host'] ?? ''));
985 if ($scheme !== 'https') {
986 throw new RuntimeException('Updates dürfen ausschließlich über HTTPS geladen werden.');
987 }
988 $hosts = array('github.com');
989 if ($allowAssetHost) {
990 $hosts[] = 'objects.githubusercontent.com';
991 $hosts[] = 'release-assets.githubusercontent.com';
992 }
993 if (!in_array($host, $hosts, true)) {
994 throw new RuntimeException('Der Update-Download wurde auf einen nicht vertrauten Host umgeleitet.');
995 }
996 if ($host === 'github.com') {
997 $path = '/' . ltrim((string)($parts['path'] ?? ''), '/');
998 if (!str_starts_with(strtolower($path), '/' . strtolower(self::OWNER_REPOSITORY) . '/releases/')) {
999 throw new RuntimeException('Die Update-URL gehört nicht zum dbxApp-Releasebereich.');
1000 }
1001 }
1002 }
1003
1007 private function readJson(string $file): array
1008 {
1009 if (!is_file($file)) {
1010 return array();
1011 }
1012 $json = file_get_contents($file);
1013 $decoded = is_string($json) ? json_decode($json, true) : null;
1014 return is_array($decoded) ? $decoded : array();
1015 }
1016
1020 private function writeJson(string $file, array $data): void
1021 {
1022 $this->ensureDirectory(dirname($file));
1023 $json = json_encode(
1024 $data,
1025 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
1026 );
1027 if (!is_string($json)) {
1028 throw new RuntimeException('Update-Status konnte nicht serialisiert werden.');
1029 }
1030 $temporary = $file . '.tmp';
1031 if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false) {
1032 throw new RuntimeException('Update-Status konnte nicht geschrieben werden.');
1033 }
1034 if (is_file($file) && !unlink($file)) {
1035 @unlink($temporary);
1036 throw new RuntimeException('Alter Update-Status konnte nicht ersetzt werden.');
1037 }
1038 if (!rename($temporary, $file)) {
1039 @unlink($temporary);
1040 throw new RuntimeException('Update-Status konnte nicht aktiviert werden.');
1041 }
1042 }
1043
1044 private function ensureDirectory(string $directory): void
1045 {
1046 if (!is_dir($directory)
1047 && !mkdir($directory, 0775, true)
1048 && !is_dir($directory)) {
1049 throw new RuntimeException('Verzeichnis konnte nicht erstellt werden: ' . $directory);
1050 }
1051 }
1052
1060 private function synchronized(callable $operation): mixed
1061 {
1062 $this->ensureDirectory($this->workDirectory);
1063 $lockFile = $this->workDirectory . DIRECTORY_SEPARATOR . 'update.lock';
1064 $lock = fopen($lockFile, 'c+');
1065 if (!is_resource($lock)) {
1066 throw new RuntimeException('Die Update-Sperre konnte nicht geöffnet werden.');
1067 }
1068
1069 try {
1070 if (!flock($lock, LOCK_EX)) {
1071 throw new RuntimeException('Die Update-Sperre konnte nicht aktiviert werden.');
1072 }
1073 return $operation();
1074 } finally {
1075 @flock($lock, LOCK_UN);
1076 fclose($lock);
1077 }
1078 }
1079
1080 private function isInside(string $path, string $root): bool
1081 {
1082 $resolvedRoot = realpath($root);
1083 $resolvedPath = realpath($path);
1084 if ($resolvedRoot === false) {
1085 return false;
1086 }
1087 if ($resolvedPath === false) {
1088 $resolvedParent = realpath(dirname($path));
1089 if ($resolvedParent === false) {
1090 return false;
1091 }
1092 $resolvedPath = $resolvedParent . DIRECTORY_SEPARATOR . basename($path);
1093 }
1094
1095 $resolvedPath = str_replace('\\', '/', rtrim($resolvedPath, '\\/'));
1096 $resolvedRoot = str_replace('\\', '/', rtrim($resolvedRoot, '\\/'));
1097 return strcasecmp($resolvedPath, $resolvedRoot) !== 0
1098 && str_starts_with(
1099 strtolower($resolvedPath),
1100 strtolower($resolvedRoot) . '/'
1101 );
1102 }
1103
1104 private function removeTree(string $directory, string $allowedRoot): void
1105 {
1106 if (!$this->isInside($directory, $allowedRoot) || !is_dir($directory)) {
1107 throw new RuntimeException('Unsicheres temporäres Löschziel wurde abgelehnt.');
1108 }
1109 $iterator = new \RecursiveIteratorIterator(
1110 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
1111 \RecursiveIteratorIterator::CHILD_FIRST
1112 );
1113 foreach ($iterator as $item) {
1114 if ($item->isDir() && !$item->isLink()) {
1115 if (!rmdir($item->getPathname())) {
1116 throw new RuntimeException('Temporäres Verzeichnis konnte nicht entfernt werden.');
1117 }
1118 } elseif (!unlink($item->getPathname())) {
1119 throw new RuntimeException('Temporäre Datei konnte nicht entfernt werden.');
1120 }
1121 }
1122 if (!rmdir($directory)) {
1123 throw new RuntimeException('Temporärer Stagingbereich konnte nicht entfernt werden.');
1124 }
1125 }
1126}
Kleine Adaptergrenze zwischen Datei-Updater und DD-Migrationsrunner.
Sicherer dbxApp Release-Updater fuer Dateien und optionale DB-Migrationen.
install()
Installs the staged package with a complete changed-file backup.
status()
Returns current, cached and staged update information.
check(bool $force=false)
Fetches and validates the trusted stable release manifest.
rollback()
Restores the complete changed-file backup of the last installation.
stage(array $manifest=array())
Downloads, hashes and extracts the newest release into isolated staging.
cancel()
Stops a prepared update before program files are changed.
prepare()
Performs the complete non-destructive part of an update in one step.
static configured()
Creates the centrally configured updater used by all admin surfaces.
__construct(string $root='', string $manifestUrl='', int $cacheTtl=21600, ?object $databaseAdapter=null)
validateManifest(array $manifest)
Validates the release contract and its fixed GitHub trust boundary.
inspectPackage(string $zipFile, array $manifest)
Validates ZIP paths, symlinks, inventory and package hashes.
parse_url($data)
Zerlegt URL-/Parameterdaten in einen Array.
Definition dbxApi.php:2416
if($resolved !==$expectedBase . 'files/test/') $config
foreach( $iterator as $file) if(! $groups) $expected
if(trim($first) !=='first') $cached
catch(RuntimeException $exception) $staging
DBX schema administration.