Files
Deploymentcenter/public/api/updateservice/v1/index.php
T
Deploymentcenter BotandClaude Opus 5 a8b9f6f7c9 feat(docs): Changelog mit "was ist seit meiner Fassung neu"
Bisher musste ein Agent, der eine Anbindung aktualisiert, die gesamte Historie
lesen - oder er las gar nichts und uebersah eine brechende Aenderung. Beides
schlecht.

- public/docs/changelog.json ist die einzige Quelle. Je Fassung eine
  Zusammenfassung, je Aenderung Bereich, ein "breaking"-Kennzeichen und vor
  allem ein Feld "action" mit dem, was konkret zu tun ist. Steht dort null,
  ist nichts zu tun - das ist die haeufigste und nuetzlichste Antwort.
- GET /api/updateservice/v1/changelog?since=2.2.0 liefert nur die neueren
  Fassungen, dazu die Anzahl der Punkte mit Handlungsbedarf und der
  brechenden Aenderungen. count:0 heisst "du bist auf Stand" - dann muss gar
  nichts gelesen werden. Optional nach Bereich filterbar (?area=packager).
- /docs/changelog.php rendert dieselbe Datei fuer Menschen, mit Eingabefeld
  fuer die eigene Fassung. Bewusst dieselbe Quelle: zwei Fassungen zu pflegen
  hiesse, sie auseinanderlaufen zu lassen.
- DeploymentcenterSdk.Version im SDK ist der Bezugspunkt. Damit muss die
  Fassung nicht abgetippt werden.
- AGENT_PROMPT_TEMPLATE.md verpflichtet dazu, sie in der AGENTS.md des
  Projekts festzuhalten und vor jeder Aenderung an der Anbindung den
  Unterschied abzufragen. Auch in der Kurzfassung fuer knappe Prompt-Budgets.

Die Historie ist rueckwirkend bis 2.0.0 gefuellt: 6 Fassungen, 25 Punkte mit
Handlungsbedarf, 13 brechende Aenderungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:45:50 +02:00

486 lines
19 KiB
PHP

<?php
/**
* UpdateService API
*
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod&platform=win-x64
* GET /api/updateservice/v1/latest?product=myapp&channel=prod&platform=win-x64
* GET /api/updateservice/v1/releases?product=myapp
* GET /api/updateservice/v1/pubkey
* POST /api/updateservice/v1/publish (Scope updateservice:publish)
*
* SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig
* ungeschuetzt. Jeder konnte download_url und sha256_hash eines bestehenden
* Releases ueberschreiben und damit allen Clients ein beliebiges Paket
* unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish".
*
* PLATTFORM: Releases tragen seit Migration 009 eine Laufzeitkennung. Ein
* Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform oder
* plattformunabhaengige ('any'). Ein Client ohne Angabe sieht ausschliesslich
* 'any' - lieber kein Update als das Paket einer fremden Plattform.
*
* Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte
* Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur
* Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\UpdateService\Version;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
$db = Db::init();
$limiter = new RateLimiter($db, 240, 60, 'updateservice');
if (!$limiter->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',
};
}