{ "openapi": "3.1.0", "info": { "title": "Manage – Protokoll v1", "version": "1.0.0", "summary": "Update- und Backup-Schnittstelle zwischen einer Projektinstanz und dem Manage-Server.", "description": "Die vier Endpunkte, die ein Client benötigt: Release-Manifest abrufen, Release-Paket herunterladen, Backup hochladen, Status melden.\n\nJede Anfrage authentifiziert sich mit zwei Headern (`X-Manage-Instance`, `X-Manage-Token`). Es gibt keine Sitzung, kein Cookie und kein gemeinsames Passwort. Der Server speichert nur den SHA-256-Hash des Tokens.\n\nJede erfolgreich authentifizierte Anfrage aktualisiert nebenbei `last_seen_at` und die letzte IP der Instanz.\n\nDie Referenzimplementierung des Clients steht vollständig im Handbuch, siehe `lib/remote.php`, `lib/updater.php` und `lib/backup.php`.", "license": { "name": "Siehe Handbuch" } }, "servers": [ { "url": "/api/v1", "description": "Diese Manage-Installation" } ], "tags": [ { "name": "Update", "description": "Release ermitteln und Paket beziehen." }, { "name": "Backup", "description": "Sicherungsarchive an den Server übertragen." }, { "name": "Status", "description": "Zustand der Instanz melden." } ], "security": [ { "instanceId": [], "instanceToken": [] } ], "paths": { "/manifest.php": { "get": { "tags": ["Update"], "operationId": "getManifest", "summary": "Release abrufen, das die Instanz installieren soll", "description": "Liefert Version, Download-Adresse, Größe und SHA-256 des aktuellen Release.\n\n`package_url` wird aus der Serverkonfiguration (`MANAGE_PUBLIC_URL`) gebildet, nicht aus dem `Host`-Header der Anfrage. Ein gefälschter Header kann einen Client daher nicht auf einen fremden Server umlenken.\n\nRelease-Metadaten sind nicht öffentlich: Der Endpunkt verlangt ein gültiges Token.", "responses": { "200": { "description": "Aktuelles Release", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Manifest" }, "example": { "success": true, "latest": "v1.3.0", "version": "v1.3.0", "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0", "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024", "size": 2199, "published_at": "2026-08-20T09:20:43+00:00" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "404": { "description": "Es ist kein gültiges Release veröffentlicht.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." } } } }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/ServerError" } } } }, "/package.php": { "get": { "tags": ["Update"], "operationId": "getPackage", "summary": "Release-ZIP herunterladen", "description": "Streamt das Release-Archiv. Antwort ist `application/zip` mit `Content-Length` und `Cache-Control: private, no-store`; bei Erfolg kein JSON.\n\nDer Client **muss** Größe und SHA-256 gegen das Manifest prüfen und die Datei bei Abweichung löschen. Ohne diese Prüfung wird beliebiger Code ausgerollt.", "parameters": [ { "name": "version", "in": "query", "required": true, "description": "Version im Format `vMAJOR.MINOR.PATCH`.", "schema": { "$ref": "#/components/schemas/Version" }, "example": "v1.3.0" } ], "responses": { "200": { "description": "Das Release-Archiv", "headers": { "Content-Disposition": { "description": "attachment; filename=\"…zip\"", "schema": { "type": "string" } }, "Content-Length": { "description": "Größe in Bytes, identisch mit `size` aus dem Manifest.", "schema": { "type": "integer" } }, "Cache-Control": { "description": "Immer `private, no-store`.", "schema": { "type": "string" } } }, "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } }, "400": { "description": "Ungültiges Versionsformat", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Ungültige Version." } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "404": { "description": "Release existiert nicht", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/ServerError" } } } }, "/backup.php": { "post": { "tags": ["Backup"], "operationId": "uploadBackup", "summary": "Backup-Archiv hochladen", "description": "Nimmt ein ZIP entgegen und legt es unter der Instanz ab.\n\nGeprüft wird in dieser Reihenfolge: Upload-Fehlercode, `is_uploaded_file`, Größenlimit (`MANAGE_BACKUP_MAX_UPLOAD_BYTES`), ZIP-Signatur, Dateinamensmuster, Prüfsumme nach dem Speichern. Weicht die Prüfsumme ab, wird die Datei wieder gelöscht und `400` gemeldet.\n\nEin vorhandener Dateiname wird nie überschrieben: Der Server hängt `-2`, `-3` an und meldet den tatsächlich verwendeten Namen zurück.\n\nFehler beim S3-Archivieren lassen den Upload **nicht** fehlschlagen – die lokale Kopie ist gespeichert und wird später nachgezogen.", "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "required": ["backup"], "properties": { "backup": { "type": "string", "format": "binary", "description": "Das ZIP-Archiv." }, "filename": { "type": "string", "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$", "description": "Gewünschter Name. Ohne Angabe vergibt der Server `backup-YYYYmmdd-HHMMSS.zip`.", "example": "backup-20260820-092104.zip" }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Prüfsumme des Archivs. Wird serverseitig neu berechnet und verglichen." }, "meta": { "type": "string", "description": "JSON-Objekt mit `trigger`, `file_count`, `source_bytes`, `app_version`.", "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}" } } }, "encoding": { "backup": { "contentType": "application/zip" } } } } }, "responses": { "200": { "description": "Backup gespeichert", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BackupResult" }, "example": { "success": true, "instance": "meinprojekt-prod", "filename": "backup-20260820-092104.zip", "size": 427, "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000", "retention": 30, "s3": { "enabled": false, "uploaded": false, "pending": 0 } } } } }, "400": { "description": "Upload abgelehnt: Datei fehlt, Limit überschritten, kein ZIP, Name oder Prüfsumme falsch.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" } } } }, "/heartbeat.php": { "post": { "tags": ["Status"], "operationId": "sendHeartbeat", "summary": "Status melden und Update-Information erhalten", "description": "Meldet den Zustand der Instanz. Alle Felder sind optional; fehlende Felder lassen den bisherigen Wert auf dem Server unverändert.\n\nDie Antwort enthält die Update-Information, ersetzt für einfache Überwachung also einen zusätzlichen Aufruf von `manifest.php`.", "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatRequest" }, "example": { "version": "v1.3.0", "php_version": "8.3.6", "disk_free": 12884901888, "pending_migrations": 0, "last_backup_at": "2026-08-20T09:21:04+00:00" } } } }, "responses": { "200": { "description": "Status übernommen", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatResponse" }, "example": { "success": true, "instance": "meinprojekt-prod", "latest": "v1.3.0", "update_available": false, "server_time": "2026-08-20T09:23:11+00:00" } } } }, "400": { "description": "Anfrage-Body ist kein gültiges JSON.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" } } } } }, "components": { "securitySchemes": { "instanceId": { "type": "apiKey", "in": "header", "name": "X-Manage-Instance", "description": "Kennung der Instanz, zum Beispiel `meinprojekt-prod`." }, "instanceToken": { "type": "apiKey", "in": "header", "name": "X-Manage-Token", "description": "Das beim Anlegen der Instanz einmalig angezeigte Token." } }, "responses": { "Unauthorized": { "description": "Header fehlen, Instanz unbekannt oder Token falsch – bewusst nicht unterscheidbar, damit Instanz-Kennungen nicht durchprobiert werden können.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." } } } }, "Disabled": { "description": "Instanz existiert, ist aber deaktiviert.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Diese Instanz ist deaktiviert." } } } }, "MethodNotAllowed": { "description": "Falsche HTTP-Methode. Die Antwort trägt einen `Allow`-Header.", "headers": { "Allow": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "RateLimited": { "description": "Zu viele fehlgeschlagene Authentifizierungen von dieser IP (`MANAGE_API_RATE_LIMIT_MAX` je `MANAGE_API_RATE_LIMIT_WINDOW` Sekunden, ab Werk 240 je 300 s). Eine erfolgreiche Anmeldung setzt den Zähler zurück.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." } } } }, "ServerError": { "description": "Unerwarteter Serverfehler; Einzelheiten stehen im Serverprotokoll.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "schemas": { "Version": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$", "examples": ["v1.3.0"] }, "Error": { "type": "object", "description": "Aufbau **aller** Fehlerantworten.", "required": ["success", "error"], "properties": { "success": { "type": "boolean", "const": false }, "error": { "type": "string", "description": "Meldung in Klartext, für Protokoll und Anzeige." } } }, "Manifest": { "type": "object", "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"], "properties": { "success": { "type": "boolean", "const": true }, "latest": { "$ref": "#/components/schemas/Version" }, "version": { "allOf": [{ "$ref": "#/components/schemas/Version" }], "description": "Gleichbedeutend mit `latest`; beide Felder existieren aus Kompatibilitätsgründen." }, "package_url": { "type": "string", "format": "uri", "description": "Absolute Adresse für den Download, aus `MANAGE_PUBLIC_URL` gebildet." }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Prüfsumme des Pakets. Vom Client zwingend zu verifizieren." }, "size": { "type": "integer", "description": "Paketgröße in Bytes." }, "published_at": { "type": "string", "format": "date-time" } } }, "BackupResult": { "type": "object", "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"], "properties": { "success": { "type": "boolean", "const": true }, "instance": { "type": "string" }, "filename": { "type": "string", "description": "Der tatsächlich vergebene Name – kann vom gewünschten abweichen, wenn er bereits belegt war." }, "size": { "type": "integer" }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "retention": { "type": "integer", "description": "Wie viele Tage der Server dieses Archiv aufbewahrt." }, "s3": { "type": "object", "description": "Zustand des optionalen S3-Archivs. Nie ein Grund für einen Fehlschlag.", "properties": { "enabled": { "type": "boolean" }, "uploaded": { "type": "boolean" }, "pending": { "type": "integer" } } } } }, "HeartbeatRequest": { "type": "object", "properties": { "version": { "type": "string", "description": "Installierte Version der Anwendung. Leer, wenn nicht ermittelbar." }, "php_version": { "type": "string", "examples": ["8.3.6"] }, "disk_free": { "type": "integer", "description": "Freier Speicher in Bytes." }, "pending_migrations": { "type": "integer", "minimum": 0 }, "last_backup_at": { "type": "string", "format": "date-time" } } }, "HeartbeatResponse": { "type": "object", "required": ["success", "instance", "latest", "update_available", "server_time"], "properties": { "success": { "type": "boolean", "const": true }, "instance": { "type": "string" }, "latest": { "type": "string", "description": "Aktuelles Release auf dem Server, oder leer, wenn keines veröffentlicht ist." }, "update_available": { "type": "boolean", "description": "Ergebnis eines `version_compare` zwischen `latest` und der gemeldeten Version. `false`, solange die Instanz keine Version meldet." }, "server_time": { "type": "string", "format": "date-time" } } } } } }