check(Http::clientIp())) { Http::fail(429, 'rate_limited', 'Zu viele Anfragen.'); } $manager = new UpdateManager($db); $action = resolveUpdateAction(); switch ($action) { case 'check': $product = Http::str('product') ?? Http::str('product_slug'); if ($product === null) { Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); } $current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0'; $channel = Http::str('channel') ?? 'prod'; $platform = Http::str('platform') ?? Http::str('rid'); $latest = $manager->checkUpdate($product, $current, $channel, $platform); if ($latest === null) { $installed = $manager->latestRelease($product, $channel, $platform); Http::ok([ 'update_available' => false, 'current_version' => $current, 'latest_version' => $installed !== null ? $installed['version'] : $current, 'platform' => UpdateManager::normalizePlatform($platform), 'message' => 'Anwendung ist aktuell.', ]); } Http::ok([ 'update_available' => true, 'current_version' => $current, 'latest_version' => $latest['version'], 'platform' => $latest['platform'] ?? UpdateManager::PLATFORM_ANY, 'is_critical' => (bool)$latest['is_critical'], 'latest_release' => $latest, ]); case 'latest': $product = Http::str('product') ?? Http::str('product_slug'); if ($product === null) { Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); } $release = $manager->latestRelease( $product, Http::str('channel') ?? 'prod', Http::str('platform') ?? Http::str('rid') ); if ($release === null) { Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product)); } Http::ok(['release' => $release]); case 'releases': $releases = $manager->getReleases( Http::str('product') ?? Http::str('product_slug'), Http::str('channel'), Http::int('limit', 200), Http::str('platform') ?? Http::str('rid') ); Http::ok(['count' => count($releases), 'releases' => $releases]); case 'changelog': // Was hat sich seit einer bestimmten Fassung geaendert? // // Damit muss ein Agent, der eine Anbindung aktualisiert, nicht die // gesamte Historie lesen. Er merkt sich die Fassung, gegen die er // integriert hat, und fragt spaeter nur nach dem Unterschied. $changelogPath = dirname(__DIR__, 3) . '/docs/changelog.json'; if (!is_file($changelogPath)) { Http::fail(404, 'no_changelog', 'Es ist kein Changelog hinterlegt.'); } $raw = (string)file_get_contents($changelogPath); // Windows-Werkzeuge stellen gern ein BOM voran; json_decode scheitert daran. $raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw; $changelog = json_decode($raw, true); if (!is_array($changelog) || !isset($changelog['versions']) || !is_array($changelog['versions'])) { Http::fail(500, 'invalid_changelog', 'Der hinterlegte Changelog ist nicht lesbar.'); } $since = Http::str('since'); $area = Http::str('area'); $entries = []; $actionItems = 0; $breaking = 0; foreach ($changelog['versions'] as $entry) { if (!is_array($entry) || !isset($entry['version'])) { continue; } // Nur echt neuere Fassungen. Wer auf 2.1.0 sitzt, will nicht // wieder ueber 2.1.0 lesen. if ($since !== null && $since !== '' && !Version::isNewer((string)$entry['version'], $since)) { continue; } if ($area !== null && $area !== '') { $entry['changes'] = array_values(array_filter( $entry['changes'] ?? [], static fn(array $c): bool => ($c['area'] ?? '') === $area )); if ($entry['changes'] === []) { continue; } } foreach ($entry['changes'] ?? [] as $change) { if (!empty($change['action'])) { $actionItems++; } if (!empty($change['breaking'])) { $breaking++; } } $entries[] = $entry; } // Absteigend: das Neueste zuerst. usort($entries, static fn(array $a, array $b): int => Version::compare((string)$b['version'], (string)$a['version'])); Http::ok([ 'current' => $changelog['current'] ?? null, 'since' => $since, 'count' => count($entries), 'action_items' => $actionItems, 'breaking' => $breaking, 'versions' => $entries, 'message' => $entries === [] ? ($since !== null && $since !== '' ? sprintf('Seit %s hat sich nichts geaendert.', $since) : 'Es ist nichts hinterlegt.') : sprintf( '%d Fassung(en) neuer als %s, davon %d mit Handlungsbedarf und %d mit Bruch.', count($entries), $since !== null && $since !== '' ? $since : 'Anbeginn', $actionItems, $breaking ), ]); case 'pubkey': // Oeffentlicher Schluessel zum Pruefen der Release-Signaturen. // Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn, // bevor er irgendetwas anderes vertrauen kann. $pub = ReleaseSigner::publicKeyPem(); if ($pub === null) { Http::fail( 404, 'signing_disabled', 'Auf diesem Deploymentcenter ist kein Signierschluessel hinterlegt ' . '(security.release_private_key). Releases werden unsigniert ausgeliefert.' ); } Http::ok([ 'algorithm' => 'RSA-SHA256', 'format' => 'canonical-line-v1', 'public_key' => $pub, 'fingerprint' => ReleaseSigner::publicKeyFingerprint(), ]); case 'publish': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.'); } $context = ApiAuth::requireScope($db, 'updateservice:publish'); $product = Http::str('product_slug') ?? Http::str('product'); $version = Http::str('version'); $url = Http::str('download_url'); $missing = []; if ($product === null) { $missing[] = 'product_slug'; } if ($version === null) { $missing[] = 'version'; } if ($url === null) { $missing[] = 'download_url'; } if ($missing !== []) { Http::fail(400, 'missing_fields', 'Pflichtfelder fehlen: ' . implode(', ', $missing), null, [ 'missing' => $missing, ]); } ApiAuth::enforceProject($context, $product); // Eine Version ohne Ziffern wuerde beim Vergleich als 0.0.0 gelten und // die Rangfolge aller Releases dieses Produkts durcheinanderbringen. if (preg_match('/^v?\d+(\.\d+)*([-+].*)?$/i', $version) !== 1) { Http::fail(400, 'invalid_version', sprintf( 'Version "%s" ist nicht interpretierbar. Erwartet wird eine Form wie 1.4.3, v1.4.3 oder 1.4.3-beta.1.', $version )); } $hash = Http::str('sha256_hash'); if ($hash !== null && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) { Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.'); } $channel = Http::str('channel') ?? 'prod'; $platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid')); $size = Http::int('size_bytes', 0); // Das Dateimanifest wandert mit in die Datenbank. Bisher blieb die // Spalte manifest_json immer leer - damit konnte die API kein // vollwertiger Rueckfall fuer den Agenten sein, wenn die statische // latest.json fehlt. $manifestJson = null; $manifestRaw = Http::input('manifest_json', null); if (is_array($manifestRaw)) { $encoded = json_encode($manifestRaw, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $manifestJson = $encoded === false ? null : $encoded; } elseif (is_string($manifestRaw) && trim($manifestRaw) !== '') { if (json_decode($manifestRaw) === null && json_last_error() !== JSON_ERROR_NONE) { Http::fail(400, 'invalid_manifest', 'manifest_json ist kein gueltiges JSON.'); } $manifestJson = $manifestRaw; } if ($manifestJson !== null && strlen($manifestJson) > 4 * 1024 * 1024) { Http::fail(400, 'manifest_too_large', 'manifest_json ueberschreitet 4 MB.'); } // Signiert wird serverseitig. Der Packager laeuft auf Entwickler- // rechnern; ein dort hinterlegter Signierschluessel waere so gut // geschuetzt wie das schwaechste dieser Systeme. $signature = ReleaseSigner::sign(ReleaseSigner::canonical( $product, $version, $channel, $platform, $hash, $url, $size )); $result = $manager->addRelease( $product, $version, $channel, Http::str('release_notes'), $url, $hash, Http::str('git_commit'), $size, $manifestJson, filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN), $context['actor'], $platform, $signature ); Http::ok([ 'release_id' => $result['id'], 'created' => $result['created'], 'platform' => $platform, 'signed' => $signature !== null, 'auto_resolved' => $result['auto_resolved'], 'message' => sprintf( 'Release %s (%s, %s) fuer "%s" %s.%s%s', $version, $channel, $platform, $product, $result['created'] ? 'veroeffentlicht' : 'aktualisiert', $signature === null ? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.' : '', $result['auto_resolved'] > 0 ? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved']) : '' ), ], $result['created'] ? 201 : 200); case 'update': case 'edit': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Das Bearbeiten eines Releases erwartet POST.'); } $context = ApiAuth::requireScope($db, 'updateservice:publish'); $releaseId = Http::int('release_id', Http::int('id', 0)); if ($releaseId <= 0) { Http::fail(400, 'missing_release_id', 'Der Parameter "release_id" oder "id" ist erforderlich.'); } $existing = $manager->getReleaseById($releaseId); if ($existing === null) { Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId)); } $product = Http::str('product_slug') ?? Http::str('product') ?? $existing['product_slug']; $version = Http::str('version') ?? $existing['version']; $url = Http::str('download_url') ?? $existing['download_url']; $channel = Http::str('channel') ?? $existing['channel']; $platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid') ?? $existing['platform']); $notes = Http::str('release_notes') ?? $existing['release_notes']; $hash = Http::str('sha256_hash') ?? $existing['sha256_hash']; $git = Http::str('git_commit') ?? $existing['git_commit']; $size = Http::int('size_bytes', (int)$existing['size_bytes']); $critical = Http::input('is_critical', null) !== null ? filter_var(Http::input('is_critical'), FILTER_VALIDATE_BOOLEAN) : (bool)$existing['is_critical']; ApiAuth::enforceProject($context, (string)$product); if ($hash !== null && $hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) { Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.'); } $signature = ReleaseSigner::sign(ReleaseSigner::canonical( (string)$product, (string)$version, (string)$channel, (string)$platform, $hash !== '' ? $hash : null, (string)$url, $size )); try { $ok = $manager->updateRelease( $releaseId, (string)$product, (string)$version, (string)$channel, $notes, (string)$url, $hash !== '' ? $hash : null, $git, $size, $existing['manifest_json'] ?? null, $critical, $context['actor'], (string)$platform, $signature ); } catch (\PDOException $e) { if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') { Http::fail(409, 'conflict', 'Ein Release mit dieser Kombination aus Produkt, Version, Kanal und Plattform existiert bereits.'); } throw $e; } Http::ok([ 'release_id' => $releaseId, 'updated' => $ok, 'platform' => $platform, 'signed' => $signature !== null, 'message' => sprintf('Release %s (%s, %s) fuer "%s" erfolgreich aktualisiert.', $version, $channel, $platform, $product), ]); case 'delete': if (Http::method() !== 'POST' && Http::method() !== 'DELETE') { Http::fail(405, 'method_not_allowed', 'Das Loeschen erwartet POST oder DELETE.'); } $context = ApiAuth::requireScope($db, 'updateservice:publish'); $releaseId = Http::int('release_id', Http::int('id', 0)); if ($releaseId <= 0) { $product = Http::str('product_slug') ?? Http::str('product'); $version = Http::str('version'); $channel = Http::str('channel') ?? 'prod'; $platform = Http::str('platform') ?? Http::str('rid'); if ($product !== null && $version !== null) { $found = $manager->findRelease($product, $version, $channel, $platform); if ($found !== null) { $releaseId = (int)$found['id']; } } } if ($releaseId <= 0) { Http::fail(400, 'missing_release_id', 'Ungueltige oder fehlende release_id.'); } $existing = $manager->getReleaseById($releaseId); if ($existing === null) { Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId)); } ApiAuth::enforceProject($context, (string)$existing['product_slug']); $deleted = $manager->deleteRelease($releaseId); if (!$deleted) { Http::fail(500, 'delete_failed', 'Das Release konnte nicht geloescht werden.'); } Http::ok([ 'release_id' => $releaseId, 'deleted' => true, 'message' => sprintf('Release v%s (%s, %s) fuer "%s" geloescht.', $existing['version'], $existing['channel'], $existing['platform'] ?? 'any', $existing['product_slug']), ]); default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'delete'], ]); } /** * Bestimmt die Aktion aus dem Pfadsegment oder ?action=. * Feste Liste statt Teilstring-Suche. */ function resolveUpdateAction(): string { $explicit = Http::str('action'); if ($explicit !== null) { // Altes Feld hiess publish_release return $explicit === 'publish_release' ? 'publish' : strtolower($explicit); } $segments = array_values(array_filter( explode('/', trim(Http::path(), '/')), static fn(string $s): bool => $s !== '' )); $last = strtolower((string)end($segments)); return match ($last) { 'check', 'latest', 'releases', 'pubkey', 'changelog', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last, 'publish_release' => 'publish', default => 'check', }; }