maintenance.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. <?php
  2. /**
  3. * Maintenance: backups and software updates, both driven by the manage client
  4. * in manage-client/ (reference: https://manage.med0.de/client-docs/).
  5. *
  6. * The page holds no update or backup logic of its own — every button calls the
  7. * same documented function the CLI calls, so `php manage-client/bin/manage-client.php
  8. * backup` and the button below do exactly the same thing. The client ships a
  9. * drop-in panel of its own; this page replaces it so the backoffice keeps one
  10. * look, one login and one CSRF token.
  11. *
  12. * Results are rendered on the POST itself rather than after a redirect: an
  13. * update reports several lines (files copied, migrations run, backup location)
  14. * and a flash message holds one.
  15. */
  16. require dirname(__DIR__) . '/app/bootstrap.php';
  17. auth_require();
  18. // An installation may legitimately not carry the client — it is one folder,
  19. // and deploying by FTP works without it. Say so instead of dying on a require.
  20. if (!is_file(APP_ROOT . '/manage-client/lib/client.php')) {
  21. admin_header('Maintenance', 'maintenance');
  22. echo '<h1>Maintenance</h1><div class="card"><p class="help" style="margin:0">'
  23. . 'The backup and update client is not installed: <code>manage-client/</code> '
  24. . 'is missing. See <code>docs/SETUP.md</code>, section 5a.</p></div>';
  25. admin_footer();
  26. exit;
  27. }
  28. require_once APP_ROOT . '/manage-client/lib/client.php';
  29. $messages = [];
  30. $errors = [];
  31. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  32. csrf_verify();
  33. // Archiving media/ and uploading it runs well past the default limit.
  34. @set_time_limit(0);
  35. $action = (string)($_POST['action'] ?? '');
  36. try {
  37. if ($action === 'download') {
  38. // Validates the filename itself and throws on anything that is not
  39. // a backup of this installation.
  40. $path = manageBackupPath((string)($_POST['filename'] ?? ''));
  41. $size = filesize($path);
  42. $fh = fopen($path, 'rb');
  43. if ($size === false || $fh === false) {
  44. throw new RuntimeException('Backup file could not be opened.');
  45. }
  46. header('Content-Type: application/zip');
  47. header('Content-Disposition: attachment; filename="' . addcslashes(basename($path), '"\\') . '"');
  48. header('Content-Length: ' . $size);
  49. header('Cache-Control: private, no-store');
  50. header('X-Content-Type-Options: nosniff');
  51. fpassthru($fh);
  52. fclose($fh);
  53. exit;
  54. }
  55. if ($action === 'backup') {
  56. $messages = array_merge($messages, maintenance_backup('manual'));
  57. manageHeartbeatSendQuietly();
  58. } elseif ($action === 'update') {
  59. // Deliberately a line here rather than a feature of the client: it
  60. // stays visible that the update takes a backup first, and the
  61. // update stops if that backup cannot be written.
  62. if (!empty($_POST['backup_first'])) {
  63. $messages = array_merge($messages, maintenance_backup('update'));
  64. }
  65. $result = manageUpdateApply(['force' => !empty($_POST['force'])]);
  66. $messages[] = sprintf(
  67. 'Update deployed: %s → %s. %d files copied, %d replaced files saved to %s.',
  68. $result['from_version'] !== '' ? $result['from_version'] : 'unknown',
  69. $result['to_version'],
  70. $result['copied'],
  71. $result['backed_up'],
  72. $result['backup_dir'],
  73. );
  74. // Files are deployed even when the hook failed; that difference is
  75. // the whole point of reporting it separately.
  76. $hook = is_array($result['hook'] ?? null) ? $result['hook'] : [];
  77. $applied = $hook['migrations']['applied'] ?? [];
  78. if ($applied !== []) {
  79. $messages[] = 'Migrations run: ' . implode(', ', $applied);
  80. }
  81. if ($hook !== [] && empty($hook['success'])) {
  82. $errors[] = empty($hook['failed_migration'])
  83. ? 'The files are deployed, but the post-update step failed: '
  84. . (string)($hook['error'] ?? 'unknown')
  85. : 'The files are deployed, but migration "' . (string)$hook['failed_migration']
  86. . '" failed: ' . (string)($hook['error'] ?? 'unknown')
  87. . ' Remaining migrations were not attempted — fix the cause, then use "Run migrations" below.';
  88. }
  89. manageHeartbeatSendQuietly();
  90. } elseif ($action === 'migrate') {
  91. $report = manageUpdateRunMigrations();
  92. if ($report['applied'] !== []) {
  93. $messages[] = 'Migrations run: ' . implode(', ', $report['applied']);
  94. }
  95. if (!$report['success']) {
  96. $errors[] = 'Migration "' . (string)$report['failed'] . '" failed: ' . (string)$report['error'];
  97. } elseif ($report['applied'] === []) {
  98. $messages[] = 'No pending migrations.';
  99. }
  100. } elseif ($action === 'heartbeat') {
  101. $result = manageHeartbeatSend();
  102. $messages[] = 'Status reported. Current release: '
  103. . (($result['latest'] ?? '') !== '' ? (string)$result['latest'] : 'none') . '.';
  104. }
  105. } catch (Throwable $e) {
  106. $errors[] = $e->getMessage();
  107. }
  108. }
  109. /**
  110. * One backup, reported as message lines. A failed upload is a warning, not a
  111. * failure: the archive exists locally either way.
  112. */
  113. function maintenance_backup(string $trigger): array
  114. {
  115. $record = manageBackupCreate($trigger);
  116. $lines = [sprintf(
  117. 'Backup created: %s — %d files, %s.',
  118. $record['filename'],
  119. $record['file_count'],
  120. manageFormatBytes((int)$record['size']),
  121. )];
  122. foreach ($record['remote_uploads'] as $upload) {
  123. if (empty($upload['success'])) {
  124. $lines[] = 'Upload to ' . (string)$upload['target'] . ' failed: '
  125. . (string)($upload['error'] ?? 'unknown') . ' The local copy is intact.';
  126. }
  127. }
  128. return $lines;
  129. }
  130. /** An interval as something readable: 604800 -> "every 7 days". */
  131. function maintenance_interval_text(int $seconds): string
  132. {
  133. if ($seconds % 86400 === 0) {
  134. $days = intdiv($seconds, 86400);
  135. return $days === 1 ? 'daily' : 'every ' . $days . ' days';
  136. }
  137. if ($seconds % 3600 === 0) {
  138. $hours = intdiv($seconds, 3600);
  139. return $hours === 1 ? 'hourly' : 'every ' . $hours . ' hours';
  140. }
  141. return 'every ' . max(1, intdiv($seconds, 60)) . ' minutes';
  142. }
  143. // The one place that asks the manage server whether a release is waiting: it is
  144. // a network round trip, so it happens when this page is opened and nowhere else.
  145. // Never throws — an unreachable server still renders the page.
  146. $status = manageClientStatus();
  147. // State of the schedule in app/manage.php.
  148. $autoInterval = (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS;
  149. $due = manage_due();
  150. $lastAutoBackup = 0;
  151. foreach ($status['backups'] as $backup) {
  152. if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
  153. $lastAutoBackup = max($lastAutoBackup, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
  154. }
  155. }
  156. $lastHeartbeat = (int)(json_read(manage_state_file())['heartbeat_at'] ?? 0);
  157. // State of the optional cronjob in app/cron.php. Its schedule lives in the
  158. // crontab, so the only thing worth showing is whether it is actually running.
  159. $cron = cron_state();
  160. $cronNote = null;
  161. if (!empty($cron['last_run_at'])) {
  162. $cronNote = 'Last ' . date('Y-m-d H:i', (int)$cron['last_run_at'])
  163. . ', ' . count($cron['jobs'] ?? []) . ' job(s) in ' . (float)($cron['duration'] ?? 0) . 's';
  164. if ((int)($cron['remaining'] ?? 0) > 0) {
  165. $cronNote .= ', ' . (int)$cron['remaining'] . ' gallery(s) still queued';
  166. }
  167. }
  168. // An instance whose server has no release yet answers the manifest with 404.
  169. // That is a normal state — a new project, nothing published — and reads far
  170. // too much like a broken connection when it is shown as a failed check.
  171. $noReleaseYet = $status['update_error'] !== null
  172. && str_contains($status['update_error'], 'HTTP 404');
  173. $update = $status['update'];
  174. $capabilities = manageRemoteCapabilities();
  175. admin_header('Maintenance', 'maintenance');
  176. flash_render();
  177. ?>
  178. <h1>Maintenance</h1>
  179. <?php foreach ($messages as $line): ?>
  180. <div class="flash flash-ok"><?= e($line) ?></div>
  181. <?php endforeach; ?>
  182. <?php foreach ($errors as $line): ?>
  183. <div class="flash flash-error"><?= e($line) ?></div>
  184. <?php endforeach; ?>
  185. <?php if (!$status['configured']): ?>
  186. <div class="flash flash-error">
  187. Not connected to the manage server. Create an instance there, then fill in
  188. <code>MANAGE_INSTANCE</code> and <code>MANAGE_TOKEN</code> in
  189. <code>manage-client/config.php</code>. Backups can still be made locally.
  190. </div>
  191. <?php endif; ?>
  192. <div class="card">
  193. <table>
  194. <tr>
  195. <td>Installed version</td>
  196. <td><?= e($status['version'] !== '' ? $status['version'] : 'unknown') ?></td>
  197. <td class="help" style="margin:0">PHP <?= e($status['php_version']) ?></td>
  198. </tr>
  199. <tr>
  200. <td>Current release</td>
  201. <td>
  202. <?php if ($update !== null && $update['available']): ?>
  203. <span class="tag tag-lock"><?= e($update['latest']) ?> available</span>
  204. <?php elseif ($update !== null): ?>
  205. <?= e($update['latest'] !== '' ? $update['latest'] : '—') ?>
  206. <?php else: ?>
  207. —
  208. <?php endif; ?>
  209. </td>
  210. <td class="help" style="margin:0">
  211. <?php if ($noReleaseYet): ?>
  212. No release has been published on the manage server yet.
  213. <?php elseif ($status['update_error'] !== null): ?>
  214. Check failed: <?= e($status['update_error']) ?>
  215. <?php elseif ($update !== null && $update['available']): ?>
  216. Back up first, then deploy.
  217. <?php elseif ($update !== null): ?>
  218. Up to date.
  219. <?php else: ?>
  220. <?= e($status['instance'] !== '' ? $status['instance'] : 'no instance configured') ?>
  221. <?php endif; ?>
  222. </td>
  223. </tr>
  224. <tr>
  225. <td>Last backup</td>
  226. <td><?= e($status['last_backup_at'] ?? 'never') ?></td>
  227. <td class="help" style="margin:0"><?= count($status['backups']) ?> kept locally</td>
  228. </tr>
  229. <tr>
  230. <td>Release migrations</td>
  231. <td><?= count($status['pending_migrations']) ?> pending</td>
  232. <td class="help" style="margin:0">
  233. <?= $status['pending_migrations'] === []
  234. ? 'Nothing to run.'
  235. : e(implode(', ', array_column($status['pending_migrations'], 'id'))) ?>
  236. </td>
  237. </tr>
  238. </table>
  239. </div>
  240. <h2>Backup</h2>
  241. <div class="card">
  242. <p class="help" style="margin-top:0">
  243. Archives <code>data/</code> and <code>media/</code> — galleries, showreel,
  244. front page, settings — and uploads it to the manage server. Gallery photos
  245. are not included: they live in the S3 bucket, which is their own backup.
  246. Nor are <code>config/</code> credentials, because a backup can be
  247. downloaded again from the server.
  248. </p>
  249. <form method="post">
  250. <?= csrf_field() ?>
  251. <input type="hidden" name="action" value="backup">
  252. <button type="submit" style="margin:0">Back up now</button>
  253. </form>
  254. </div>
  255. <h2>Schedule</h2>
  256. <div class="card">
  257. <p class="help" style="margin-top:0">
  258. This host is assumed to have no cron, so the backoffice drives both jobs:
  259. opening any admin page past the interval starts them in the background
  260. (<code>manage-worker.php</code>). Updates are never part of that, and the
  261. release check runs only when this page is opened.
  262. </p>
  263. <table>
  264. <tr>
  265. <td>Automatic backup</td>
  266. <td><?= $autoInterval > 0 ? e(maintenance_interval_text($autoInterval)) : 'off' ?></td>
  267. <td class="help" style="margin:0">
  268. <?php if ($autoInterval <= 0): ?>
  269. <code>MANAGE_BACKUP_AUTO_INTERVAL_SECONDS</code> is 0 — only cron or the button above make backups.
  270. <?php elseif (in_array('backup', $due, true)): ?>
  271. Due now — starts on the next admin page load.
  272. <?php else: ?>
  273. Next <?= e(date('Y-m-d H:i', $lastAutoBackup + $autoInterval)) ?>.
  274. <?php endif; ?>
  275. </td>
  276. </tr>
  277. <tr>
  278. <td>Heartbeat</td>
  279. <td><?= e(maintenance_interval_text(manage_heartbeat_interval())) ?></td>
  280. <td class="help" style="margin:0">
  281. <?php if ($lastHeartbeat === 0): ?>
  282. Not sent yet.
  283. <?php else: ?>
  284. Last <?= e(date('Y-m-d H:i', $lastHeartbeat)) ?>.
  285. <?php endif; ?>
  286. </td>
  287. </tr>
  288. <tr>
  289. <td>Cronjob</td>
  290. <td><?= cron_enabled() ? 'enabled' : 'not enabled' ?></td>
  291. <td class="help" style="margin:0">
  292. <?php if (!cron_enabled()): ?>
  293. Optional. The jobs above and the gallery archives run without it.
  294. <?php elseif ($cronNote === null): ?>
  295. Enabled, but <code>cron.php</code> has not run yet — check the crontab line.
  296. <?php else: ?>
  297. <?= e($cronNote) ?>.
  298. <?php endif; ?>
  299. </td>
  300. </tr>
  301. </table>
  302. <p class="help">
  303. If the host does offer cron, one line is enough:
  304. <code>cron.php?key=…</code> every five minutes, or the same file from the
  305. shell. It drains every due gallery archive and whatever the schedule owes,
  306. and the web fallback above simply finds nothing left. The key is in
  307. <code>data/worker-key.json</code>; set <code>cron.enabled</code> in
  308. <code>config/config.php</code> and see <code>scripts/manage-client.cron</code>.
  309. </p>
  310. </div>
  311. <h2>Update</h2>
  312. <div class="card">
  313. <?php if ($update !== null && $update['available']): ?>
  314. <p style="margin-top:0">
  315. Version <strong><?= e($update['latest']) ?></strong> is ready
  316. <?php if (!empty($update['manifest']['published_at'])): ?>
  317. (published <?= e($update['manifest']['published_at']) ?>)
  318. <?php endif; ?>.
  319. </p>
  320. <?php endif; ?>
  321. <p class="help" style="margin-top:0">
  322. Files are replaced while the site stays online, and there is no rollback:
  323. the replaced files are copied to <code>data/manage/updates/</code> for
  324. manual recovery. <code>config/</code>, <code>data/</code> and
  325. <code>media/</code> are never touched. Deleted files are not removed —
  326. an update overlays what is there.
  327. <?php if ($status['pending_migrations'] === []): ?>
  328. Afterwards, check <a href="migrate.php">Data migration</a>.
  329. <?php endif; ?>
  330. </p>
  331. <form method="post" onsubmit="return confirm('Deploy the update now? Files will be overwritten.');">
  332. <?= csrf_field() ?>
  333. <input type="hidden" name="action" value="update">
  334. <p class="help" style="margin-bottom:.4rem"><label style="display:inline;text-transform:none;letter-spacing:0">
  335. <input type="checkbox" name="backup_first" value="1" checked>
  336. Create a backup first
  337. </label></p>
  338. <p class="help" style="margin-bottom:.4rem"><label style="display:inline;text-transform:none;letter-spacing:0">
  339. <input type="checkbox" name="force" value="1">
  340. Deploy even if no newer version is offered
  341. </label></p>
  342. <button type="submit" style="margin-top:1rem"
  343. <?= $update !== null && !$update['available'] ? 'class="btn-ghost"' : '' ?>>
  344. Deploy update
  345. </button>
  346. </form>
  347. </div>
  348. <?php if ($status['pending_migrations'] !== []): ?>
  349. <h2>Pending release migrations</h2>
  350. <div class="card">
  351. <p class="help" style="margin-top:0">
  352. Shipped with a release and normally run by the update itself. These are
  353. left over — usually because one failed, or because the update ran with
  354. migrations skipped.
  355. </p>
  356. <table style="margin-bottom:1rem">
  357. <?php foreach ($status['pending_migrations'] as $migration): ?>
  358. <tr><td><?= e($migration['id']) ?></td></tr>
  359. <?php endforeach; ?>
  360. </table>
  361. <form method="post">
  362. <?= csrf_field() ?>
  363. <input type="hidden" name="action" value="migrate">
  364. <button type="submit" style="margin:0">Run migrations</button>
  365. </form>
  366. </div>
  367. <?php endif; ?>
  368. <h2>Local backups</h2>
  369. <div class="card">
  370. <?php if ($status['backups'] === []): ?>
  371. <p class="help" style="margin:0">No backup has been made yet.</p>
  372. <?php else: ?>
  373. <table>
  374. <tr>
  375. <th>File</th><th>Created</th><th>Trigger</th>
  376. <th>Files</th><th>Size</th><th>Upload</th><th></th>
  377. </tr>
  378. <?php foreach ($status['backups'] as $backup): ?>
  379. <tr>
  380. <td><?= e((string)($backup['filename'] ?? '')) ?></td>
  381. <td><?= e((string)($backup['created_at'] ?? '')) ?></td>
  382. <td><?= e((string)($backup['trigger'] ?? '')) ?></td>
  383. <td><?= (int)($backup['file_count'] ?? 0) ?></td>
  384. <td><?= e(manageFormatBytes((int)($backup['size'] ?? 0))) ?></td>
  385. <td>
  386. <?php $uploads = is_array($backup['remote_uploads'] ?? null) ? $backup['remote_uploads'] : []; ?>
  387. <?php if ($uploads === []): ?>
  388. —
  389. <?php else: foreach ($uploads as $upload): ?>
  390. <span class="tag <?= empty($upload['success']) ? 'tag-expired' : 'tag-lock' ?>">
  391. <?= e((string)($upload['target'] ?? '?')) ?><?= empty($upload['success']) ? ' failed' : '' ?>
  392. </span>
  393. <?php endforeach; endif; ?>
  394. </td>
  395. <td>
  396. <form method="post">
  397. <?= csrf_field() ?>
  398. <input type="hidden" name="action" value="download">
  399. <input type="hidden" name="filename" value="<?= e((string)($backup['filename'] ?? '')) ?>">
  400. <button type="submit" class="btn-ghost" style="margin:0;padding:.4rem 1rem">Download</button>
  401. </form>
  402. </td>
  403. </tr>
  404. <?php endforeach; ?>
  405. </table>
  406. <?php endif; ?>
  407. <p class="help">
  408. Kept locally: <?= (int)MANAGE_BACKUP_LOCAL_RETENTION ?>. Older ones are
  409. deleted here after each new backup; the manage server keeps its own,
  410. longer history.
  411. </p>
  412. </div>
  413. <?php
  414. $unsupported = [];
  415. foreach ($capabilities as $type => $capability) {
  416. if ($capability['configured'] && !$capability['available']) {
  417. $unsupported[] = $type;
  418. }
  419. }
  420. ?>
  421. <?php if ($unsupported !== []): ?>
  422. <div class="flash flash-error">
  423. Configured backup targets this server cannot use:
  424. <?= e(implode(', ', $unsupported)) ?>. Those uploads will fail.
  425. </div>
  426. <?php endif; ?>
  427. <?php foreach ($status['errors'] as $line): ?>
  428. <div class="flash flash-error"><?= e($line) ?></div>
  429. <?php endforeach; ?>
  430. <div class="card">
  431. <h2 style="margin-top:0">Report status</h2>
  432. <p class="help" style="margin-top:0">
  433. Sends version, PHP version, free disk space and the time of the last
  434. backup to the manage server. Normally an hourly cron job; this is the
  435. manual version of it.
  436. </p>
  437. <form method="post">
  438. <?= csrf_field() ?>
  439. <input type="hidden" name="action" value="heartbeat">
  440. <button type="submit" class="btn-ghost" style="margin:0">Send heartbeat</button>
  441. </form>
  442. </div>
  443. <p class="help">
  444. Same operations from the shell:
  445. <code>php manage-client/bin/manage-client.php status|check|backup|update|migrate|heartbeat</code>.
  446. See <code>docs/SETUP.md</code>.
  447. </p>
  448. <?php admin_footer(); ?>