openapi.json 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. {
  2. "openapi": "3.1.0",
  3. "info": {
  4. "title": "Manage – Protokoll v1",
  5. "version": "1.0.0",
  6. "summary": "Update- und Backup-Schnittstelle zwischen einer Projektinstanz und dem Manage-Server.",
  7. "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`.",
  8. "license": {
  9. "name": "Siehe Handbuch"
  10. }
  11. },
  12. "servers": [
  13. {
  14. "url": "/api/v1",
  15. "description": "Diese Manage-Installation"
  16. }
  17. ],
  18. "tags": [
  19. {
  20. "name": "Update",
  21. "description": "Release ermitteln und Paket beziehen."
  22. },
  23. {
  24. "name": "Backup",
  25. "description": "Sicherungsarchive an den Server übertragen."
  26. },
  27. {
  28. "name": "Status",
  29. "description": "Zustand der Instanz melden."
  30. }
  31. ],
  32. "security": [
  33. {
  34. "instanceId": [],
  35. "instanceToken": []
  36. }
  37. ],
  38. "paths": {
  39. "/manifest.php": {
  40. "get": {
  41. "tags": ["Update"],
  42. "operationId": "getManifest",
  43. "summary": "Release abrufen, das die Instanz installieren soll",
  44. "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.",
  45. "responses": {
  46. "200": {
  47. "description": "Aktuelles Release",
  48. "content": {
  49. "application/json": {
  50. "schema": { "$ref": "#/components/schemas/Manifest" },
  51. "example": {
  52. "success": true,
  53. "latest": "v1.3.0",
  54. "version": "v1.3.0",
  55. "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0",
  56. "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024",
  57. "size": 2199,
  58. "published_at": "2026-08-20T09:20:43+00:00"
  59. }
  60. }
  61. }
  62. },
  63. "401": { "$ref": "#/components/responses/Unauthorized" },
  64. "403": { "$ref": "#/components/responses/Disabled" },
  65. "404": {
  66. "description": "Es ist kein gültiges Release veröffentlicht.",
  67. "content": {
  68. "application/json": {
  69. "schema": { "$ref": "#/components/schemas/Error" },
  70. "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." }
  71. }
  72. }
  73. },
  74. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  75. "429": { "$ref": "#/components/responses/RateLimited" },
  76. "500": { "$ref": "#/components/responses/ServerError" }
  77. }
  78. }
  79. },
  80. "/package.php": {
  81. "get": {
  82. "tags": ["Update"],
  83. "operationId": "getPackage",
  84. "summary": "Release-ZIP herunterladen",
  85. "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.",
  86. "parameters": [
  87. {
  88. "name": "version",
  89. "in": "query",
  90. "required": true,
  91. "description": "Version im Format `vMAJOR.MINOR.PATCH`.",
  92. "schema": { "$ref": "#/components/schemas/Version" },
  93. "example": "v1.3.0"
  94. }
  95. ],
  96. "responses": {
  97. "200": {
  98. "description": "Das Release-Archiv",
  99. "headers": {
  100. "Content-Disposition": {
  101. "description": "attachment; filename=\"…zip\"",
  102. "schema": { "type": "string" }
  103. },
  104. "Content-Length": {
  105. "description": "Größe in Bytes, identisch mit `size` aus dem Manifest.",
  106. "schema": { "type": "integer" }
  107. },
  108. "Cache-Control": {
  109. "description": "Immer `private, no-store`.",
  110. "schema": { "type": "string" }
  111. }
  112. },
  113. "content": {
  114. "application/zip": {
  115. "schema": { "type": "string", "format": "binary" }
  116. }
  117. }
  118. },
  119. "400": {
  120. "description": "Ungültiges Versionsformat",
  121. "content": {
  122. "application/json": {
  123. "schema": { "$ref": "#/components/schemas/Error" },
  124. "example": { "success": false, "error": "Ungültige Version." }
  125. }
  126. }
  127. },
  128. "401": { "$ref": "#/components/responses/Unauthorized" },
  129. "403": { "$ref": "#/components/responses/Disabled" },
  130. "404": {
  131. "description": "Release existiert nicht",
  132. "content": {
  133. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  134. }
  135. },
  136. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  137. "429": { "$ref": "#/components/responses/RateLimited" },
  138. "500": { "$ref": "#/components/responses/ServerError" }
  139. }
  140. }
  141. },
  142. "/backup.php": {
  143. "post": {
  144. "tags": ["Backup"],
  145. "operationId": "uploadBackup",
  146. "summary": "Backup-Archiv hochladen",
  147. "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.",
  148. "requestBody": {
  149. "required": true,
  150. "content": {
  151. "multipart/form-data": {
  152. "schema": {
  153. "type": "object",
  154. "required": ["backup"],
  155. "properties": {
  156. "backup": {
  157. "type": "string",
  158. "format": "binary",
  159. "description": "Das ZIP-Archiv."
  160. },
  161. "filename": {
  162. "type": "string",
  163. "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$",
  164. "description": "Gewünschter Name. Ohne Angabe vergibt der Server `backup-YYYYmmdd-HHMMSS.zip`.",
  165. "example": "backup-20260820-092104.zip"
  166. },
  167. "sha256": {
  168. "type": "string",
  169. "pattern": "^[a-f0-9]{64}$",
  170. "description": "Prüfsumme des Archivs. Wird serverseitig neu berechnet und verglichen."
  171. },
  172. "meta": {
  173. "type": "string",
  174. "description": "JSON-Objekt mit `trigger`, `file_count`, `source_bytes`, `app_version`.",
  175. "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}"
  176. }
  177. }
  178. },
  179. "encoding": {
  180. "backup": { "contentType": "application/zip" }
  181. }
  182. }
  183. }
  184. },
  185. "responses": {
  186. "200": {
  187. "description": "Backup gespeichert",
  188. "content": {
  189. "application/json": {
  190. "schema": { "$ref": "#/components/schemas/BackupResult" },
  191. "example": {
  192. "success": true,
  193. "instance": "meinprojekt-prod",
  194. "filename": "backup-20260820-092104.zip",
  195. "size": 427,
  196. "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000",
  197. "retention": 30,
  198. "s3": { "enabled": false, "uploaded": false, "pending": 0 }
  199. }
  200. }
  201. }
  202. },
  203. "400": {
  204. "description": "Upload abgelehnt: Datei fehlt, Limit überschritten, kein ZIP, Name oder Prüfsumme falsch.",
  205. "content": {
  206. "application/json": {
  207. "schema": { "$ref": "#/components/schemas/Error" },
  208. "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." }
  209. }
  210. }
  211. },
  212. "401": { "$ref": "#/components/responses/Unauthorized" },
  213. "403": { "$ref": "#/components/responses/Disabled" },
  214. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  215. "429": { "$ref": "#/components/responses/RateLimited" }
  216. }
  217. }
  218. },
  219. "/heartbeat.php": {
  220. "post": {
  221. "tags": ["Status"],
  222. "operationId": "sendHeartbeat",
  223. "summary": "Status melden und Update-Information erhalten",
  224. "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`.",
  225. "requestBody": {
  226. "required": false,
  227. "content": {
  228. "application/json": {
  229. "schema": { "$ref": "#/components/schemas/HeartbeatRequest" },
  230. "example": {
  231. "version": "v1.3.0",
  232. "php_version": "8.3.6",
  233. "disk_free": 12884901888,
  234. "pending_migrations": 0,
  235. "last_backup_at": "2026-08-20T09:21:04+00:00"
  236. }
  237. }
  238. }
  239. },
  240. "responses": {
  241. "200": {
  242. "description": "Status übernommen",
  243. "content": {
  244. "application/json": {
  245. "schema": { "$ref": "#/components/schemas/HeartbeatResponse" },
  246. "example": {
  247. "success": true,
  248. "instance": "meinprojekt-prod",
  249. "latest": "v1.3.0",
  250. "update_available": false,
  251. "server_time": "2026-08-20T09:23:11+00:00"
  252. }
  253. }
  254. }
  255. },
  256. "400": {
  257. "description": "Anfrage-Body ist kein gültiges JSON.",
  258. "content": {
  259. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  260. }
  261. },
  262. "401": { "$ref": "#/components/responses/Unauthorized" },
  263. "403": { "$ref": "#/components/responses/Disabled" },
  264. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  265. "429": { "$ref": "#/components/responses/RateLimited" }
  266. }
  267. }
  268. }
  269. },
  270. "components": {
  271. "securitySchemes": {
  272. "instanceId": {
  273. "type": "apiKey",
  274. "in": "header",
  275. "name": "X-Manage-Instance",
  276. "description": "Kennung der Instanz, zum Beispiel `meinprojekt-prod`."
  277. },
  278. "instanceToken": {
  279. "type": "apiKey",
  280. "in": "header",
  281. "name": "X-Manage-Token",
  282. "description": "Das beim Anlegen der Instanz einmalig angezeigte Token."
  283. }
  284. },
  285. "responses": {
  286. "Unauthorized": {
  287. "description": "Header fehlen, Instanz unbekannt oder Token falsch – bewusst nicht unterscheidbar, damit Instanz-Kennungen nicht durchprobiert werden können.",
  288. "content": {
  289. "application/json": {
  290. "schema": { "$ref": "#/components/schemas/Error" },
  291. "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." }
  292. }
  293. }
  294. },
  295. "Disabled": {
  296. "description": "Instanz existiert, ist aber deaktiviert.",
  297. "content": {
  298. "application/json": {
  299. "schema": { "$ref": "#/components/schemas/Error" },
  300. "example": { "success": false, "error": "Diese Instanz ist deaktiviert." }
  301. }
  302. }
  303. },
  304. "MethodNotAllowed": {
  305. "description": "Falsche HTTP-Methode. Die Antwort trägt einen `Allow`-Header.",
  306. "headers": {
  307. "Allow": { "schema": { "type": "string" } }
  308. },
  309. "content": {
  310. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  311. }
  312. },
  313. "RateLimited": {
  314. "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.",
  315. "content": {
  316. "application/json": {
  317. "schema": { "$ref": "#/components/schemas/Error" },
  318. "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." }
  319. }
  320. }
  321. },
  322. "ServerError": {
  323. "description": "Unerwarteter Serverfehler; Einzelheiten stehen im Serverprotokoll.",
  324. "content": {
  325. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  326. }
  327. }
  328. },
  329. "schemas": {
  330. "Version": {
  331. "type": "string",
  332. "pattern": "^v\\d+\\.\\d+\\.\\d+$",
  333. "examples": ["v1.3.0"]
  334. },
  335. "Error": {
  336. "type": "object",
  337. "description": "Aufbau **aller** Fehlerantworten.",
  338. "required": ["success", "error"],
  339. "properties": {
  340. "success": { "type": "boolean", "const": false },
  341. "error": { "type": "string", "description": "Meldung in Klartext, für Protokoll und Anzeige." }
  342. }
  343. },
  344. "Manifest": {
  345. "type": "object",
  346. "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"],
  347. "properties": {
  348. "success": { "type": "boolean", "const": true },
  349. "latest": { "$ref": "#/components/schemas/Version" },
  350. "version": {
  351. "allOf": [{ "$ref": "#/components/schemas/Version" }],
  352. "description": "Gleichbedeutend mit `latest`; beide Felder existieren aus Kompatibilitätsgründen."
  353. },
  354. "package_url": {
  355. "type": "string",
  356. "format": "uri",
  357. "description": "Absolute Adresse für den Download, aus `MANAGE_PUBLIC_URL` gebildet."
  358. },
  359. "sha256": {
  360. "type": "string",
  361. "pattern": "^[a-f0-9]{64}$",
  362. "description": "Prüfsumme des Pakets. Vom Client zwingend zu verifizieren."
  363. },
  364. "size": { "type": "integer", "description": "Paketgröße in Bytes." },
  365. "published_at": { "type": "string", "format": "date-time" }
  366. }
  367. },
  368. "BackupResult": {
  369. "type": "object",
  370. "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"],
  371. "properties": {
  372. "success": { "type": "boolean", "const": true },
  373. "instance": { "type": "string" },
  374. "filename": {
  375. "type": "string",
  376. "description": "Der tatsächlich vergebene Name – kann vom gewünschten abweichen, wenn er bereits belegt war."
  377. },
  378. "size": { "type": "integer" },
  379. "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
  380. "retention": {
  381. "type": "integer",
  382. "description": "Wie viele Tage der Server dieses Archiv aufbewahrt."
  383. },
  384. "s3": {
  385. "type": "object",
  386. "description": "Zustand des optionalen S3-Archivs. Nie ein Grund für einen Fehlschlag.",
  387. "properties": {
  388. "enabled": { "type": "boolean" },
  389. "uploaded": { "type": "boolean" },
  390. "pending": { "type": "integer" }
  391. }
  392. }
  393. }
  394. },
  395. "HeartbeatRequest": {
  396. "type": "object",
  397. "properties": {
  398. "version": {
  399. "type": "string",
  400. "description": "Installierte Version der Anwendung. Leer, wenn nicht ermittelbar."
  401. },
  402. "php_version": { "type": "string", "examples": ["8.3.6"] },
  403. "disk_free": { "type": "integer", "description": "Freier Speicher in Bytes." },
  404. "pending_migrations": { "type": "integer", "minimum": 0 },
  405. "last_backup_at": { "type": "string", "format": "date-time" }
  406. }
  407. },
  408. "HeartbeatResponse": {
  409. "type": "object",
  410. "required": ["success", "instance", "latest", "update_available", "server_time"],
  411. "properties": {
  412. "success": { "type": "boolean", "const": true },
  413. "instance": { "type": "string" },
  414. "latest": {
  415. "type": "string",
  416. "description": "Aktuelles Release auf dem Server, oder leer, wenn keines veröffentlicht ist."
  417. },
  418. "update_available": {
  419. "type": "boolean",
  420. "description": "Ergebnis eines `version_compare` zwischen `latest` und der gemeldeten Version. `false`, solange die Instanz keine Version meldet."
  421. },
  422. "server_time": { "type": "string", "format": "date-time" }
  423. }
  424. }
  425. }
  426. }
  427. }