manage.php 17 KB

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