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 '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); default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'], ]); } /** * 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', 'publish' => $last, 'publish_release' => 'publish', default => 'check', }; }