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;
29 private string $workDirectory;
30 private string $manifestUrl;
31 private int $cacheTtl;
32 private ?
object $databaseAdapter;
36 string $manifestUrl =
'',
37 int $cacheTtl = 21600,
38 ?
object $databaseAdapter =
null
40 $resolvedRoot = realpath($root !==
'' ? $root : dirname(__DIR__, 4));
41 if ($resolvedRoot ===
false || !is_dir($resolvedRoot)) {
42 throw new RuntimeException(
'dbxApp-Projektwurzel wurde nicht gefunden.');
45 $this->root = rtrim($resolvedRoot,
'\\/');
46 $this->workDirectory = $this->root . DIRECTORY_SEPARATOR
47 .
'files' . DIRECTORY_SEPARATOR .
'update';
48 $this->manifestUrl = $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
62 $this->databaseAdapter = $databaseAdapter;
75 dbx()->get_include_obj(
'dbxDatabaseMigrationService',
'dbxAdmin');
76 dbx()->get_include_obj(
'dbxUpdateDatabaseAdapter',
'dbxAdmin');
82 ? (string)(
$config[
'update_manifest_url'] ??
'')
85 ? (int)(
$config[
'update_cache_ttl'] ?? 21600)
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');
105 $current = $this->currentVersion();
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'] ??
'') !==
'',
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'] ??
''),
133 $cacheFile = $this->workDirectory . DIRECTORY_SEPARATOR .
'check.json';
134 $cached = $this->readJson($cacheFile);
135 $checkedAt = strtotime((
string)(
$cached[
'checked_at'] ??
'')) ?: 0;
137 && is_array(
$cached[
'manifest'] ??
null)
138 && $checkedAt >= time() - $this->cacheTtl) {
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.');
148 $this->writeJson($cacheFile, array(
149 'checked_at' => gmdate(
'c'),
169 if (!version_compare(
$manifest[
'version'], $this->currentVersion(),
'>')) {
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.');
196 if (!preg_match(
'/^\d+\.\d+\.\d+$/',
$version)) {
197 throw new RuntimeException(
'Das Update-Manifest enthält keine stabile Version.');
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.');
205 $base =
'https://github.com/' . self::OWNER_REPOSITORY;
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.');
214 $requires = is_array(
$manifest[
'requires'] ??
null)
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.');
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 .
'.'
229 $extensions = is_array($requires[
'extensions'] ??
null)
230 ? $requires[
'extensions']
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.');
237 if (!extension_loaded($extension)) {
238 throw new RuntimeException(
'Für das Update fehlt die PHP-Erweiterung ' . $extension .
'.');
245 'php' => $phpRequirement,
260 : $this->
check(
false);
262 return $this->
synchronized(
263 fn(): array => $this->stagePackage(
$manifest)
271 private function stagePackage(array
$manifest): array
273 if (!version_compare(
$manifest[
'version'], $this->currentVersion(),
'>')) {
274 throw new RuntimeException(
'Es ist kein neueres stabiles Update verfügbar.');
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';
283 $stagingRoot = $this->workDirectory . DIRECTORY_SEPARATOR .
'staging';
284 $this->ensureDirectory($stagingRoot);
288 if (is_file($temporary) && !unlink($temporary)) {
289 throw new RuntimeException(
'Ein unvollständiger Update-Download konnte nicht entfernt werden.');
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.');
298 throw new RuntimeException(
'Ein altes Update-Paket konnte nicht ersetzt werden.');
300 if (!rename($temporary,
$zipFile)) {
301 throw new RuntimeException(
'Das geprüfte Update-Paket konnte nicht bereitgestellt werden.');
305 $this->removeTree(
$staging, $stagingRoot);
310 $zip =
new ZipArchive();
311 if ($zip->open(
$zipFile) !==
true) {
312 throw new RuntimeException(
'Das Update-Paket konnte nicht isoliert geöffnet werden.');
316 throw new RuntimeException(
'Das Update-Paket konnte nicht isoliert entpackt werden.');
325 'staged_at' => gmdate(
'c'),
332 $this->workDirectory . DIRECTORY_SEPARATOR .
'staged.json',
336 }
catch (Throwable $exception) {
341 $this->removeTree(
$staging, $stagingRoot);
342 }
catch (Throwable) {
358 if (!class_exists(ZipArchive::class)) {
359 throw new RuntimeException(
'Für Updates wird die PHP-Erweiterung zip benötigt.');
362 throw new RuntimeException(
'Das Update-Paket wurde nicht gefunden.');
366 $zip =
new ZipArchive();
367 if ($zip->open(
$zipFile) !==
true) {
368 throw new RuntimeException(
'Das Update-Paket ist kein lesbares ZIP-Archiv.');
372 if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_PACKAGE_FILES) {
373 throw new RuntimeException(
'Das Update-Paket enthält eine unzulässige Dateianzahl.');
380 $stat = $zip->statIndex(
$index);
381 if (!is_array($stat)) {
382 throw new RuntimeException(
'Ein ZIP-Eintrag konnte nicht geprüft werden.');
384 $raw = (string)($stat[
'name'] ??
'');
385 $isDirectory = str_ends_with($raw,
'/');
386 $relative = $this->normalizeArchivePath($raw);
387 if ($relative ===
'') {
391 throw new RuntimeException(
'Das Update-Paket enthält einen leeren Dateinamen.');
396 if ($zip->getExternalAttributesIndex(
$index, $operations, $attributes)
397 && (($attributes >> 16) & 0170000) === 0120000) {
398 throw new RuntimeException(
'Symbolische Links sind in Update-Paketen nicht erlaubt.');
403 if ($this->isProtectedPath($relative)) {
404 throw new RuntimeException(
'Das Update-Paket enthält lokale Laufzeitdaten: ' . $relative);
407 $caseKey = strtolower($relative);
408 if (isset($caseMap[$caseKey])) {
409 throw new RuntimeException(
'Das Update-Paket enthält kollidierende Dateinamen.');
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ß.');
419 foreach (array(
'VERSION',
'index.php',
'dbx/include/dbxApi.php',
'.dbx-release-files.json') as
$required) {
421 throw new RuntimeException(
'Im Update-Paket fehlt ' .
$required .
'.');
425 $version = trim((
string)$zip->getFromName(
'VERSION'));
427 throw new RuntimeException(
'VERSION im Update-Paket stimmt nicht mit dem Manifest überein.');
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.');
441 foreach ($inventory[
'files'] as $relative => $expectedHash) {
442 $relative = $this->normalizeArchivePath((
string)$relative);
443 if ($relative ===
'.dbx-release-files.json') {
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.');
451 $content = $zip->getFromName($relative);
453 || !hash_equals($expectedHash, hash(
'sha256',
$content))) {
454 throw new RuntimeException(
'Dateiprüfsumme stimmt nicht: ' . $relative);
456 $hashes[$relative] = $expectedHash;
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.');
465 return array(
'files' =>
$files,
'hashes' => $hashes);
478 return $this->
synchronized(
479 fn(): array => $this->installPackage()
486 private function installPackage(): array
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.');
498 if (!$this->isInside(
$zipFile, $this->workDirectory)
499 || !$this->isInside(
$staging, $this->workDirectory)
501 throw new RuntimeException(
'Der Update-Stagingbereich ist ungültig.');
504 if (!is_string($actualHash)
505 || !hash_equals(
$manifest[
'sha256'], strtolower($actualHash))) {
506 throw new RuntimeException(
'Das bereitgestellte Update-Paket wurde verändert.');
511 $current = $this->currentVersion();
512 if (!version_compare(
$manifest[
'version'], $current,
'>')) {
513 throw new RuntimeException(
'Das bereitgestellte Update ist nicht neuer als die Installation.');
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);
528 foreach (array_values(array_unique(array_merge($newFiles, $obsolete))) as $relative) {
529 $relative = $this->normalizeArchivePath($relative);
530 if ($relative ===
'' || $this->isProtectedPath($relative)) {
533 $destination = $this->destination($relative);
534 $existed = is_file($destination);
537 . str_replace(
'/', DIRECTORY_SEPARATOR, $relative);
540 throw new RuntimeException(
'Sicherung fehlgeschlagen: ' . $relative);
545 'existed' => $existed,
546 'obsolete' => in_array($relative, $obsolete,
true),
550 $backupState = array(
552 'from_version' => $current,
554 'created_at' => gmdate(
'c'),
556 'entries' => $entries,
558 $databaseState = array();
559 if ($this->databaseAdapter !==
null) {
560 $databaseState = $this->databaseAdapter->prepare(
565 $backupState[
'database'] = $databaseState;
567 $this->writeJson(
$backup . DIRECTORY_SEPARATOR .
'backup.json', $backupState);
570 foreach ($obsolete as $relative) {
571 $relative = $this->normalizeArchivePath($relative);
572 if ($relative ===
'' || $this->isProtectedPath($relative)) {
575 $destination = $this->destination($relative);
576 if (is_file($destination) && !unlink($destination)) {
577 throw new RuntimeException(
'Veraltete Datei konnte nicht entfernt werden: ' . $relative);
581 foreach ($newFiles as $relative) {
582 $relative = $this->normalizeArchivePath($relative);
584 . str_replace(
'/', DIRECTORY_SEPARATOR, $relative);
585 $destination = $this->destination($relative);
587 throw new RuntimeException(
'Staging-Datei fehlt: ' . $relative);
589 $this->ensureDirectory(dirname($destination));
590 if (!copy(
$source, $destination)) {
591 throw new RuntimeException(
'Update-Datei konnte nicht installiert werden: ' . $relative);
595 if ($this->databaseAdapter !==
null && $databaseState !== array()) {
596 $backupState[
'database_result'] = $this->databaseAdapter->apply(
600 $backup . DIRECTORY_SEPARATOR .
'backup.json',
605 if ($this->currentVersion() !==
$manifest[
'version']) {
606 throw new RuntimeException(
'Die installierte VERSION konnte nicht bestätigt werden.');
608 }
catch (Throwable $exception) {
609 $rollbackErrors = array();
610 if ($this->databaseAdapter !==
null && $databaseState !== array()) {
612 $this->databaseAdapter->rollback($databaseState);
613 }
catch (Throwable $databaseRollbackError) {
614 $rollbackErrors[] =
'DB-Rollback: ' . $databaseRollbackError->getMessage();
618 $this->restoreBackup($backupState);
619 }
catch (Throwable $fileRollbackError) {
620 $rollbackErrors[] =
'Datei-Rollback: ' . $fileRollbackError->getMessage();
622 $rollbackSuffix = $rollbackErrors !== array()
623 ?
' Rollbackfehler: ' . implode(
'; ', $rollbackErrors)
625 throw new RuntimeException(
626 'Update fehlgeschlagen und wurde zurückgerollt: '
627 . $exception->getMessage() . $rollbackSuffix,
633 $installed = $backupState + array(
'installed_at' => gmdate(
'c'));
635 $this->workDirectory . DIRECTORY_SEPARATOR .
'installed.json',
639 $this->discardStagedState(
false);
640 }
catch (Throwable $cleanupError) {
641 @unlink($this->workDirectory . DIRECTORY_SEPARATOR .
'staged.json');
642 $installed[
'cleanup_warning'] = $cleanupError->getMessage();
644 $this->workDirectory . DIRECTORY_SEPARATOR .
'installed.json',
648 if (function_exists(
'opcache_reset')) {
665 return $this->
synchronized(
function (): array {
666 $state = $this->discardStagedState(
true);
668 'version' => (
string)(
$state[
'manifest'][
'version'] ??
''),
669 'stopped_at' => gmdate(
'c'),
681 return $this->
synchronized(
682 fn(): array => $this->rollbackInstalledPackage()
689 private function rollbackInstalledPackage(): array
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.');
698 if ($this->currentVersion() !== (
string)(
$state[
'to_version'] ??
'')) {
699 throw new RuntimeException(
'Die installierte Version wurde seit dem Update verändert.');
702 if ($this->databaseAdapter !==
null
703 && is_array(
$state[
'database'] ??
null)
704 &&
$state[
'database'] !== array()
706 $this->databaseAdapter->rollback(
$state[
'database']);
708 $this->restoreBackup(
$state);
709 $state[
'rolled_back_at'] = gmdate(
'c');
710 $this->writeJson($stateFile,
$state);
711 if (function_exists(
'opcache_reset')) {
723 private function discardStagedState(
bool $required): array
725 $stateFile = $this->workDirectory . DIRECTORY_SEPARATOR .
'staged.json';
726 $state = $this->readJson($stateFile);
727 if (!is_array(
$state[
'manifest'] ??
null)) {
729 throw new RuntimeException(
'Es ist kein vorbereitetes Update vorhanden.');
737 if ($path !==
'' && !$this->isInside($path, $this->workDirectory)) {
738 throw new RuntimeException(
'Der vorbereitete Update-Status ist ungültig.');
743 $this->removeTree(
$staging, $this->workDirectory);
747 throw new RuntimeException(
'Eine vorbereitete Update-Datei konnte nicht entfernt werden.');
750 if (is_file($stateFile) && !unlink($stateFile)) {
751 throw new RuntimeException(
'Der vorbereitete Update-Status konnte nicht entfernt werden.');
759 private function restoreBackup(array
$state): void
762 if (!$this->isInside(
$backup, $this->workDirectory)) {
763 throw new RuntimeException(
'Das Sicherungsverzeichnis liegt außerhalb des Updatebereichs.');
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)) {
771 $destination = $this->destination($relative);
772 if (!empty($entry[
'existed'])) {
774 . DIRECTORY_SEPARATOR . str_replace(
'/', DIRECTORY_SEPARATOR, $relative);
776 throw new RuntimeException(
'Sicherungsdatei fehlt: ' . $relative);
778 $this->ensureDirectory(dirname($destination));
779 if (!copy(
$source, $destination)) {
780 throw new RuntimeException(
'Sicherungsdatei konnte nicht wiederhergestellt werden: ' . $relative);
782 } elseif (is_file($destination) && !unlink($destination)) {
783 throw new RuntimeException(
'Neu installierte Datei konnte nicht entfernt werden: ' . $relative);
791 private function installedInventory(): array
793 $inventory = $this->readJson(
794 $this->root . DIRECTORY_SEPARATOR .
'.dbx-release-files.json'
796 if ((
int)($inventory[
'schema'] ?? 0) !== 1
797 || (
string)($inventory[
'product'] ??
'') !== self::PRODUCT
798 || !is_array($inventory[
'files'] ??
null)) {
803 foreach (array_keys($inventory[
'files']) as $relative) {
805 $relative = $this->normalizeArchivePath((
string)$relative);
806 }
catch (Throwable) {
809 if ($relative !==
'' && !$this->isProtectedPath($relative)) {
813 return array_values(array_unique(
$files));
819 private function verifyExtractedFiles(
string $staging, array $hashes): void
821 foreach ($hashes as $relative =>
$expected) {
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);
831 private function currentVersion(): string
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.');
841 private function destination(
string $relative): string
843 $relative = $this->normalizeArchivePath($relative);
844 if ($relative ===
'' || $this->isProtectedPath($relative)) {
845 throw new RuntimeException(
'Ungültiger Installationspfad: ' . $relative);
847 return $this->root . DIRECTORY_SEPARATOR
848 . str_replace(
'/', DIRECTORY_SEPARATOR, $relative);
851 private function normalizeArchivePath(
string $path): string
853 if (str_contains($path,
"\0") || str_contains($path,
'\\')) {
854 throw new RuntimeException(
'Das Update-Paket enthält einen unsicheren Pfad.');
857 if ($path ===
'' || str_starts_with($path,
'/') || preg_match(
'/^[A-Za-z]:/', $path)) {
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.');
866 return implode(
'/', $parts);
869 private function isProtectedPath(
string $relative): bool
871 $relative = ltrim(str_replace(
'\\',
'/', $relative),
'/');
872 $base = basename($relative);
873 $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
874 if (
$base ===
'.gitkeep') {
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',
891 private function downloadText(
string $url,
int $maximumBytes): string
894 $this->curlRequest(
$url,
static function ($handle,
string $chunk) use (&
$content, $maximumBytes):
int {
895 if (strlen(
$content) + strlen($chunk) > $maximumBytes) {
899 return strlen($chunk);
904 private function downloadFile(
string $url,
string $target,
int $maximumBytes): void
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.');
914 $this->curlRequest(
$url,
static function ($handle,
string $chunk) use ($stream, &
$written, $maximumBytes):
int {
915 $length = strlen($chunk);
916 if (
$written + $length > $maximumBytes) {
919 $result = fwrite($stream, $chunk);
934 private function curlRequest(
string $url, callable $writer): void
936 if (!extension_loaded(
'curl')) {
937 throw new RuntimeException(
'Für die Update-Prüfung wird die PHP-Erweiterung curl benötigt.');
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.');
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,
956 if (defined(
'CURLOPT_PROTOCOLS') && defined(
'CURLPROTO_HTTPS')) {
957 $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTPS;
959 if (defined(
'CURLOPT_REDIR_PROTOCOLS') && defined(
'CURLPROTO_HTTPS')) {
960 $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTPS;
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);
969 if (
$ok !==
true || $status < 200 || $status >= 300) {
970 throw new RuntimeException(
971 'Update-Download fehlgeschlagen'
972 . ($status > 0 ?
' (HTTP ' . $status .
')' :
'')
973 . ($error !==
'' ?
': ' . $error :
'.'),
977 $this->assertTrustedUrl($effective,
true);
980 private function assertTrustedUrl(
string $url,
bool $allowAssetHost): void
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.');
988 $hosts = array(
'github.com');
989 if ($allowAssetHost) {
990 $hosts[] =
'objects.githubusercontent.com';
991 $hosts[] =
'release-assets.githubusercontent.com';
993 if (!in_array($host, $hosts,
true)) {
994 throw new RuntimeException(
'Der Update-Download wurde auf einen nicht vertrauten Host umgeleitet.');
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.');
1007 private function readJson(
string $file): array
1009 if (!is_file(
$file)) {
1013 $decoded = is_string(
$json) ? json_decode(
$json,
true) : null;
1014 return is_array($decoded) ? $decoded : array();
1020 private function writeJson(
string $file, array $data): void
1022 $this->ensureDirectory(dirname(
$file));
1023 $json = json_encode(
1025 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
1027 if (!is_string(
$json)) {
1028 throw new RuntimeException(
'Update-Status konnte nicht serialisiert werden.');
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.');
1035 @unlink($temporary);
1036 throw new RuntimeException(
'Alter Update-Status konnte nicht ersetzt werden.');
1038 if (!rename($temporary,
$file)) {
1039 @unlink($temporary);
1040 throw new RuntimeException(
'Update-Status konnte nicht aktiviert werden.');
1044 private function ensureDirectory(
string $directory): void
1046 if (!is_dir($directory)
1047 && !mkdir($directory, 0775,
true)
1048 && !is_dir($directory)) {
1049 throw new RuntimeException(
'Verzeichnis konnte nicht erstellt werden: ' . $directory);
1060 private function synchronized(callable $operation): mixed
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.');
1070 if (!flock($lock, LOCK_EX)) {
1071 throw new RuntimeException(
'Die Update-Sperre konnte nicht aktiviert werden.');
1073 return $operation();
1075 @flock($lock, LOCK_UN);
1080 private function isInside(
string $path,
string $root): bool
1082 $resolvedRoot = realpath($root);
1083 $resolvedPath = realpath($path);
1084 if ($resolvedRoot ===
false) {
1087 if ($resolvedPath ===
false) {
1088 $resolvedParent = realpath(dirname($path));
1089 if ($resolvedParent ===
false) {
1092 $resolvedPath = $resolvedParent . DIRECTORY_SEPARATOR . basename($path);
1095 $resolvedPath = str_replace(
'\\',
'/', rtrim($resolvedPath,
'\\/'));
1096 $resolvedRoot = str_replace(
'\\',
'/', rtrim($resolvedRoot,
'\\/'));
1097 return strcasecmp($resolvedPath, $resolvedRoot) !== 0
1099 strtolower($resolvedPath),
1100 strtolower($resolvedRoot) .
'/'
1104 private function removeTree(
string $directory,
string $allowedRoot): void
1106 if (!$this->isInside($directory, $allowedRoot) || !is_dir($directory)) {
1107 throw new RuntimeException(
'Unsicheres temporäres Löschziel wurde abgelehnt.');
1109 $iterator = new \RecursiveIteratorIterator(
1110 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
1111 \RecursiveIteratorIterator::CHILD_FIRST
1114 if ($item->isDir() && !$item->isLink()) {
1115 if (!rmdir($item->getPathname())) {
1116 throw new RuntimeException(
'Temporäres Verzeichnis konnte nicht entfernt werden.');
1118 } elseif (!unlink($item->getPathname())) {
1119 throw new RuntimeException(
'Temporäre Datei konnte nicht entfernt werden.');
1122 if (!rmdir($directory)) {
1123 throw new RuntimeException(
'Temporärer Stagingbereich konnte nicht entfernt werden.');