client.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. <?php
  2. declare(strict_types=1);
  3. // Manage client core: configuration defaults, filesystem helpers, the
  4. // authenticated HTTP transport and version file handling.
  5. //
  6. // This is the only file a host project needs to require. It pulls in the rest
  7. // of the library, so both the CLI and the GUI panel start here:
  8. //
  9. // require_once __DIR__ . "/manage-client/lib/client.php";
  10. // $status = manageClientStatus();
  11. $manageClientConfigFile = dirname(__DIR__) . "/config.php";
  12. if (is_file($manageClientConfigFile)) {
  13. require_once $manageClientConfigFile;
  14. }
  15. if (!defined("MANAGE_SERVER_URL")) {
  16. define("MANAGE_SERVER_URL", "");
  17. }
  18. if (!defined("MANAGE_INSTANCE")) {
  19. define("MANAGE_INSTANCE", "");
  20. }
  21. if (!defined("MANAGE_TOKEN")) {
  22. define("MANAGE_TOKEN", "");
  23. }
  24. if (!defined("MANAGE_HTTP_TIMEOUT")) {
  25. define("MANAGE_HTTP_TIMEOUT", 15);
  26. }
  27. if (!defined("MANAGE_HTTP_TIMEOUT_LONG")) {
  28. define("MANAGE_HTTP_TIMEOUT_LONG", 300);
  29. }
  30. if (!defined("MANAGE_APP_ROOT")) {
  31. define("MANAGE_APP_ROOT", dirname(__DIR__, 2));
  32. }
  33. if (!defined("MANAGE_VERSION_FILE")) {
  34. define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/VERSION");
  35. }
  36. if (!defined("MANAGE_VERSION_CONSTANT")) {
  37. define("MANAGE_VERSION_CONSTANT", null);
  38. }
  39. if (!defined("MANAGE_WORK_DIR")) {
  40. define("MANAGE_WORK_DIR", MANAGE_APP_ROOT . "/data/manage/work/");
  41. }
  42. if (!defined("MANAGE_UPDATE_BACKUP_DIR")) {
  43. define("MANAGE_UPDATE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/updates/");
  44. }
  45. if (!defined("MANAGE_BACKUP_DIR")) {
  46. define("MANAGE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/backups/");
  47. }
  48. if (!defined("MANAGE_LOG_FILE")) {
  49. define("MANAGE_LOG_FILE", MANAGE_APP_ROOT . "/data/manage/manage-client.log");
  50. }
  51. if (!defined("MANAGE_UPDATE_PROTECTED_PATHS")) {
  52. define("MANAGE_UPDATE_PROTECTED_PATHS", ["config.php", "data/", ".git/", "manage-client/config.php"]);
  53. }
  54. if (!defined("MANAGE_UPDATE_SANITY_PATHS")) {
  55. define("MANAGE_UPDATE_SANITY_PATHS", ["index.php"]);
  56. }
  57. if (!defined("MANAGE_UPDATE_POST_HOOK")) {
  58. define("MANAGE_UPDATE_POST_HOOK", null);
  59. }
  60. if (!defined("MANAGE_MIGRATIONS_DIR")) {
  61. define("MANAGE_MIGRATIONS_DIR", MANAGE_APP_ROOT . "/migrations");
  62. }
  63. if (!defined("MANAGE_MIGRATIONS_STATE")) {
  64. define("MANAGE_MIGRATIONS_STATE", MANAGE_APP_ROOT . "/data/manage/migrations.json");
  65. }
  66. if (!defined("MANAGE_BACKUP_SOURCES")) {
  67. define("MANAGE_BACKUP_SOURCES", [["as" => "data", "glob" => "data/*.json"]]);
  68. }
  69. if (!defined("MANAGE_BACKUP_DATABASE")) {
  70. define("MANAGE_BACKUP_DATABASE", null);
  71. }
  72. if (!defined("MANAGE_BACKUP_LOCAL_RETENTION")) {
  73. define("MANAGE_BACKUP_LOCAL_RETENTION", 4);
  74. }
  75. if (!defined("MANAGE_BACKUP_AUTO_INTERVAL_SECONDS")) {
  76. define("MANAGE_BACKUP_AUTO_INTERVAL_SECONDS", 604800);
  77. }
  78. if (!defined("MANAGE_BACKUP_COMPRESS")) {
  79. define("MANAGE_BACKUP_COMPRESS", true);
  80. }
  81. if (!defined("MANAGE_BACKUP_UPLOAD")) {
  82. define("MANAGE_BACKUP_UPLOAD", true);
  83. }
  84. if (!defined("MANAGE_BACKUP_REMOTE_TARGETS")) {
  85. define("MANAGE_BACKUP_REMOTE_TARGETS", []);
  86. }
  87. // Raised when a remote upload fails. Carries a scrubbed debug context that is
  88. // safe to log: credentials are never part of it.
  89. class ManageRemoteUploadException extends RuntimeException
  90. {
  91. private array $debugContext;
  92. public function __construct(string $message, array $debugContext = [])
  93. {
  94. parent::__construct($message);
  95. $this->debugContext = $debugContext;
  96. }
  97. public function getDebugContext(): array
  98. {
  99. return $this->debugContext;
  100. }
  101. }
  102. // ---------------------------------------------------------------------------
  103. // Paths
  104. // ---------------------------------------------------------------------------
  105. function manageClientAppRoot(): string
  106. {
  107. $root = realpath((string) MANAGE_APP_ROOT);
  108. if ($root === false) {
  109. throw new RuntimeException("MANAGE_APP_ROOT existiert nicht: " . MANAGE_APP_ROOT);
  110. }
  111. return rtrim($root, "/\\");
  112. }
  113. function manageClientPath(string $relative): string
  114. {
  115. return manageClientAppRoot() . DIRECTORY_SEPARATOR . ltrim($relative, "/\\");
  116. }
  117. function manageClientNormalizePath(string $path): string
  118. {
  119. return str_replace("\\", "/", $path);
  120. }
  121. function manageEnsureDir(string $dir): void
  122. {
  123. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  124. throw new RuntimeException("Verzeichnis konnte nicht erstellt werden: " . $dir);
  125. }
  126. @chmod($dir, 02775);
  127. }
  128. function manageRemoveDir(string $dir): void
  129. {
  130. if (!is_dir($dir)) {
  131. return;
  132. }
  133. $items = new RecursiveIteratorIterator(
  134. new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
  135. RecursiveIteratorIterator::CHILD_FIRST,
  136. );
  137. foreach ($items as $item) {
  138. if ($item->isDir()) {
  139. @rmdir($item->getPathname());
  140. } else {
  141. @unlink($item->getPathname());
  142. }
  143. }
  144. @rmdir($dir);
  145. }
  146. function manageIsTemporaryFile(string $path): bool
  147. {
  148. $name = basename($path);
  149. return $name === "" ||
  150. $name[0] === "." ||
  151. str_ends_with($name, ".tmp") ||
  152. str_ends_with($name, ".part");
  153. }
  154. function manageFormatBytes(int $bytes): string
  155. {
  156. if ($bytes >= 1073741824) {
  157. return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
  158. }
  159. if ($bytes >= 1048576) {
  160. return number_format($bytes / 1048576, 2, ",", ".") . " MB";
  161. }
  162. if ($bytes >= 1024) {
  163. return number_format($bytes / 1024, 1, ",", ".") . " KB";
  164. }
  165. return $bytes . " B";
  166. }
  167. // ---------------------------------------------------------------------------
  168. // JSON state files
  169. // ---------------------------------------------------------------------------
  170. function manageReadJson(string $file): array
  171. {
  172. if (!is_file($file)) {
  173. return [];
  174. }
  175. $content = file_get_contents($file);
  176. if ($content === false || trim($content) === "") {
  177. return [];
  178. }
  179. $decoded = json_decode($content, true);
  180. return is_array($decoded) ? $decoded : [];
  181. }
  182. function manageWriteJson(string $file, array $data): void
  183. {
  184. manageEnsureDir(dirname($file));
  185. $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  186. if ($json === false) {
  187. throw new RuntimeException("JSON konnte nicht kodiert werden: " . basename($file));
  188. }
  189. $tmpFile = $file . ".tmp";
  190. if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
  191. throw new RuntimeException("Datei konnte nicht geschrieben werden: " . basename($file));
  192. }
  193. @chmod($tmpFile, 0664);
  194. if (!rename($tmpFile, $file)) {
  195. @unlink($tmpFile);
  196. throw new RuntimeException("Datei konnte nicht gespeichert werden: " . basename($file));
  197. }
  198. @chmod($file, 0664);
  199. }
  200. // ---------------------------------------------------------------------------
  201. // Logging
  202. // ---------------------------------------------------------------------------
  203. // Appends one JSON line. Never throws: a failed log write must not abort an
  204. // update or a backup.
  205. function manageClientLog(string $level, string $message, array $context = []): void
  206. {
  207. $file = (string) MANAGE_LOG_FILE;
  208. if ($file === "") {
  209. return;
  210. }
  211. try {
  212. manageEnsureDir(dirname($file));
  213. } catch (Throwable $exception) {
  214. return;
  215. }
  216. // Simple size cap; the host application owns its own log rotation.
  217. if (is_file($file) && (int) (filesize($file) ?: 0) > 2097152) {
  218. @rename($file, $file . ".1");
  219. }
  220. $line = json_encode([
  221. "timestamp" => date("Y-m-d H:i:s"),
  222. "level" => $level,
  223. "message" => $message,
  224. "context" => $context,
  225. ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  226. if (is_string($line)) {
  227. @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
  228. }
  229. }
  230. // ---------------------------------------------------------------------------
  231. // Version file
  232. // ---------------------------------------------------------------------------
  233. function manageIsVersionString(string $version): bool
  234. {
  235. return preg_match('/^v\d+\.\d+\.\d+$/', trim($version)) === 1;
  236. }
  237. function manageVersionCompareValue(string $version): string
  238. {
  239. return ltrim(trim($version), "vV");
  240. }
  241. /**
  242. * Reads the installed version. Supports both supported layouts:
  243. * - a PHP file defining a constant (MANAGE_VERSION_CONSTANT)
  244. * - a plain text file containing only the version
  245. *
  246. * Returns "" when the version cannot be determined, which the callers treat as
  247. * "unknown" rather than as an error.
  248. */
  249. function manageClientVersion(): string
  250. {
  251. $file = (string) MANAGE_VERSION_FILE;
  252. if ($file === "" || !is_file($file)) {
  253. return "";
  254. }
  255. $constant = MANAGE_VERSION_CONSTANT;
  256. if (is_string($constant) && $constant !== "") {
  257. // The constant may already be defined by the host application.
  258. if (defined($constant)) {
  259. $value = (string) constant($constant);
  260. if (manageIsVersionString($value)) {
  261. return trim($value);
  262. }
  263. }
  264. // Otherwise parse it out of the file without executing it: the file may
  265. // have side effects, and including it twice would fatal on redefinition.
  266. $content = (string) file_get_contents($file);
  267. $pattern = '/define\s*\(\s*["\']' . preg_quote($constant, "/") . '["\']\s*,\s*["\'](v?\d+\.\d+\.\d+)["\']/';
  268. if (preg_match($pattern, $content, $matches) === 1) {
  269. return trim($matches[1]);
  270. }
  271. return "";
  272. }
  273. $value = trim((string) file_get_contents($file));
  274. return manageIsVersionString($value) ? $value : "";
  275. }
  276. // ---------------------------------------------------------------------------
  277. // HTTP transport
  278. // ---------------------------------------------------------------------------
  279. function manageClientConfigured(): bool
  280. {
  281. return trim((string) MANAGE_SERVER_URL) !== "" &&
  282. trim((string) MANAGE_INSTANCE) !== "" &&
  283. trim((string) MANAGE_TOKEN) !== "";
  284. }
  285. function manageClientRequireConfigured(): void
  286. {
  287. if (manageClientConfigured()) {
  288. return;
  289. }
  290. throw new RuntimeException(
  291. "Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und MANAGE_TOKEN " .
  292. "müssen in manage-client/config.php gesetzt sein.",
  293. );
  294. }
  295. function manageClientEndpoint(string $path): string
  296. {
  297. $base = rtrim(trim((string) MANAGE_SERVER_URL), "/");
  298. return $base . "/api/v1/" . ltrim($path, "/");
  299. }
  300. function manageClientUserAgent(): string
  301. {
  302. $version = manageClientVersion();
  303. return "Manage-Client/1.0 (" . (string) MANAGE_INSTANCE . "; app " . ($version !== "" ? $version : "unknown") . ")";
  304. }
  305. function manageClientAuthHeaders(): string
  306. {
  307. return "X-Manage-Instance: " . (string) MANAGE_INSTANCE . "\r\n" .
  308. "X-Manage-Token: " . (string) MANAGE_TOKEN . "\r\n" .
  309. "User-Agent: " . manageClientUserAgent() . "\r\n";
  310. }
  311. function manageClientStatusFromHeaders(array $headers): int
  312. {
  313. $status = 0;
  314. foreach ($headers as $header) {
  315. if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) {
  316. $status = (int) $matches[1];
  317. }
  318. }
  319. return $status;
  320. }
  321. function manageClientResponseHeaders($legacyHeaders): array
  322. {
  323. if (function_exists("http_get_last_response_headers")) {
  324. $lastHeaders = http_get_last_response_headers();
  325. return is_array($lastHeaders) ? $lastHeaders : [];
  326. }
  327. return is_array($legacyHeaders) ? $legacyHeaders : [];
  328. }
  329. // Turns a server error response into a message worth reading. The API always
  330. // answers with {"success":false,"error":"..."}; anything else is truncated.
  331. function manageClientErrorMessage(int $status, $body): string
  332. {
  333. $suffix = $status > 0 ? " (HTTP " . $status . ")" : "";
  334. if (is_string($body) && $body !== "") {
  335. $decoded = json_decode($body, true);
  336. if (is_array($decoded) && isset($decoded["error"])) {
  337. return trim((string) $decoded["error"]) . $suffix;
  338. }
  339. $excerpt = substr(trim(preg_replace('/\s+/', " ", $body) ?? ""), 0, 300);
  340. if ($excerpt !== "") {
  341. return $excerpt . $suffix;
  342. }
  343. }
  344. return "Anfrage fehlgeschlagen" . ($suffix !== "" ? $suffix : " (keine Antwort vom Server)") . ".";
  345. }
  346. /**
  347. * Performs an authenticated request against the manage server.
  348. *
  349. * @param string $method GET or POST
  350. * @param string $path endpoint below api/v1/
  351. * @param string|null $body raw request body for POST
  352. * @param string $contentType
  353. * @param int|null $timeout seconds; defaults to MANAGE_HTTP_TIMEOUT
  354. *
  355. * @return array{status: int, body: string}
  356. */
  357. function manageClientRequest(
  358. string $method,
  359. string $path,
  360. ?string $body = null,
  361. string $contentType = "application/json",
  362. ?int $timeout = null,
  363. ): array {
  364. manageClientRequireConfigured();
  365. $url = manageClientEndpoint($path);
  366. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  367. throw new RuntimeException("Ungültige Server-URL: " . $url);
  368. }
  369. $headers = manageClientAuthHeaders() . "Accept: application/json\r\n";
  370. $options = [
  371. "method" => $method,
  372. "timeout" => $timeout ?? (int) MANAGE_HTTP_TIMEOUT,
  373. "ignore_errors" => true,
  374. "follow_location" => 0,
  375. "protocol_version" => 1.1,
  376. ];
  377. if ($body !== null) {
  378. $headers .= "Content-Type: " . $contentType . "\r\n";
  379. $headers .= "Content-Length: " . strlen($body) . "\r\n";
  380. $options["content"] = $body;
  381. }
  382. $options["header"] = $headers;
  383. $context = stream_context_create(["http" => $options]);
  384. $response = @file_get_contents($url, false, $context);
  385. $status = manageClientStatusFromHeaders(manageClientResponseHeaders($http_response_header ?? null));
  386. if ($response === false && $status === 0) {
  387. throw new RuntimeException("Manage-Server ist nicht erreichbar: " . $url);
  388. }
  389. return ["status" => $status, "body" => is_string($response) ? $response : ""];
  390. }
  391. /**
  392. * Authenticated request that expects a JSON object and a 2xx status.
  393. */
  394. function manageClientRequestJson(
  395. string $method,
  396. string $path,
  397. ?array $payload = null,
  398. ?int $timeout = null,
  399. ): array {
  400. $body = $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  401. if ($payload !== null && $body === false) {
  402. throw new RuntimeException("Anfrage konnte nicht kodiert werden.");
  403. }
  404. $response = manageClientRequest($method, $path, $body, "application/json", $timeout);
  405. if ($response["status"] < 200 || $response["status"] >= 300) {
  406. throw new RuntimeException(manageClientErrorMessage($response["status"], $response["body"]));
  407. }
  408. $decoded = json_decode($response["body"], true);
  409. if (!is_array($decoded)) {
  410. throw new RuntimeException("Antwort des Servers ist kein gültiges JSON.");
  411. }
  412. return $decoded;
  413. }
  414. require_once __DIR__ . "/zip.php";
  415. require_once __DIR__ . "/mysql.php";
  416. require_once __DIR__ . "/remote.php";
  417. require_once __DIR__ . "/backup.php";
  418. require_once __DIR__ . "/hooks.php";
  419. require_once __DIR__ . "/updater.php";
  420. require_once __DIR__ . "/heartbeat.php";
  421. /**
  422. * Aggregate status used by the CLI, the GUI panel and any host integration.
  423. * Never throws: every remote failure is reported inside the returned array, so
  424. * a settings page can render even when the server is unreachable.
  425. */
  426. function manageClientStatus(): array
  427. {
  428. $status = [
  429. "instance" => (string) MANAGE_INSTANCE,
  430. "server_url" => (string) MANAGE_SERVER_URL,
  431. "configured" => manageClientConfigured(),
  432. "version" => manageClientVersion(),
  433. "php_version" => PHP_VERSION,
  434. "update" => null,
  435. "update_error" => null,
  436. "backups" => [],
  437. "last_backup_at" => null,
  438. "pending_migrations" => [],
  439. "errors" => [],
  440. ];
  441. try {
  442. $status["backups"] = manageBackupList();
  443. $status["last_backup_at"] = $status["backups"] === []
  444. ? null
  445. : (string) ($status["backups"][0]["created_at"] ?? "");
  446. } catch (Throwable $exception) {
  447. $status["errors"][] = $exception->getMessage();
  448. }
  449. try {
  450. $status["pending_migrations"] = manageUpdatePendingMigrations();
  451. } catch (Throwable $exception) {
  452. $status["errors"][] = $exception->getMessage();
  453. }
  454. if ($status["configured"]) {
  455. try {
  456. $status["update"] = manageUpdateCheck();
  457. } catch (Throwable $exception) {
  458. $status["update_error"] = $exception->getMessage();
  459. }
  460. }
  461. return $status;
  462. }