manage.php 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. <?php
  2. /**
  3. * The backup schedule, for hosting without cron.
  4. *
  5. * The manage client in manage-client/ can do everything from the command line,
  6. * but this application's typical host offers no dependable cron — so the two
  7. * recurring jobs are driven by the web instead, the same way gallery archives
  8. * are (see app/archive.php):
  9. *
  10. * - **backup**, when the newest one is older than
  11. * MANAGE_BACKUP_AUTO_INTERVAL_SECONDS (a week by default),
  12. * - **heartbeat**, hourly, so the manage server can tell a silent
  13. * installation from a healthy one.
  14. *
  15. * An admin page load past the interval calls manage_kick(), which hands the
  16. * work to manage-worker.php in the background and returns immediately. The
  17. * backoffice is the trigger rather than the public site because these jobs
  18. * exist for the operator, and an installation nobody administers is one nobody
  19. * needs a fresh backup of.
  20. *
  21. * Deliberately not scheduled here:
  22. *
  23. * - **Updates.** Never automatic; they overwrite files under a live site and
  24. * have no rollback. Admin -> Maintenance, on purpose.
  25. * - **The update check.** It is a request to the manage server, and doing it
  26. * per page load would put a network round trip in front of the backoffice.
  27. * The Maintenance page checks when it is opened, and the heartbeat response
  28. * carries the same information as a side effect.
  29. *
  30. * Where real cron does exist, it calls manage-worker.php (or the CLI) on a
  31. * timer and this all still holds — the jobs are the same code either way.
  32. */
  33. declare(strict_types=1);
  34. /** Whether the manage client is installed and configured on this instance. */
  35. function manage_available(): bool
  36. {
  37. return is_file(APP_ROOT . '/manage-client/config.php')
  38. && is_file(APP_ROOT . '/manage-client/lib/client.php');
  39. }
  40. /** Load the client library. Safe to call repeatedly. */
  41. function manage_load(): void
  42. {
  43. require_once APP_ROOT . '/manage-client/lib/client.php';
  44. }
  45. /**
  46. * Marks the last time a kick was dispatched. Its mtime is the only thing read,
  47. * so an admin page costs one stat() when nothing is due.
  48. */
  49. function manage_tick_file(): string
  50. {
  51. return DATA_DIR . '/manage.tick';
  52. }
  53. function manage_state_file(): string
  54. {
  55. return DATA_DIR . '/manage/web-schedule.json';
  56. }
  57. /** Seconds between heartbeats. */
  58. function manage_heartbeat_interval(): int
  59. {
  60. return (int)config('manage.heartbeat_interval', 3600);
  61. }
  62. /**
  63. * Which jobs are due right now: 'backup', 'heartbeat', or neither.
  64. *
  65. * The backup side asks the client rather than deciding itself — the interval
  66. * lives in manage-client/config.php, and manageBackupCreateAutomaticIfDue()
  67. * measures it against the same backup index the Maintenance page shows.
  68. */
  69. function manage_due(): array
  70. {
  71. if (!manage_available()) {
  72. return [];
  73. }
  74. manage_load();
  75. $due = [];
  76. if ((int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS > 0) {
  77. $last = 0;
  78. foreach (manageBackupList() as $backup) {
  79. // Only backups this schedule made count towards it: a manual one
  80. // from the Maintenance page should not postpone the routine.
  81. if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
  82. $last = max($last, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
  83. }
  84. }
  85. if (time() - $last >= (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS) {
  86. $due[] = 'backup';
  87. }
  88. }
  89. $state = json_read(manage_state_file());
  90. if (time() - (int)($state['heartbeat_at'] ?? 0) >= manage_heartbeat_interval()) {
  91. $due[] = 'heartbeat';
  92. }
  93. return $due;
  94. }
  95. /**
  96. * Run whatever is due. Returns a line per job for the log; never throws, because
  97. * every caller is a page that has something better to do than fail over this.
  98. */
  99. function manage_run_due(): array
  100. {
  101. if (!manage_available()) {
  102. return [];
  103. }
  104. manage_load();
  105. $done = [];
  106. $due = manage_due();
  107. if (in_array('backup', $due, true)) {
  108. try {
  109. // Returns null if another look at the clock says it is not due
  110. // after all — two workers racing, or a backup made in between.
  111. $record = manageBackupCreateAutomaticIfDue();
  112. if ($record !== null) {
  113. $done[] = 'backup ' . $record['filename'];
  114. }
  115. } catch (Throwable $e) {
  116. manageClientLog('ERROR', 'Scheduled backup failed', ['error' => $e->getMessage()]);
  117. $done[] = 'backup failed: ' . $e->getMessage();
  118. }
  119. }
  120. if (in_array('heartbeat', $due, true)) {
  121. // Records the attempt either way: an unreachable server must not turn
  122. // into a heartbeat on every single page load.
  123. json_update(manage_state_file(), function (array $state): array {
  124. $state['heartbeat_at'] = time();
  125. return $state;
  126. });
  127. $done[] = manageHeartbeatSendQuietly() === null ? 'heartbeat failed' : 'heartbeat';
  128. }
  129. return $done;
  130. }
  131. /** URL of manage-worker.php on this installation. */
  132. function manage_worker_url(): ?string
  133. {
  134. return self_url('/manage-worker.php?key=' . rawurlencode(archive_worker_key()));
  135. }
  136. /**
  137. * Called at the end of every admin page. Cheap when nothing is due: one stat()
  138. * on the tick file, and the client library is not even loaded.
  139. *
  140. * The dispatch-then-fall-back-to-inline shape is archive_kick()'s, for the same
  141. * reason — a host that blocks outbound HTTP to itself would otherwise never run
  142. * these jobs at all. Inline only happens after the page has been flushed to the
  143. * operator, so a weekly backup of a few hundred megabytes is never something
  144. * they sit and watch.
  145. */
  146. function manage_kick(): void
  147. {
  148. $tick = manage_tick_file();
  149. // Long enough that a click-happy session costs nothing, short enough that
  150. // a backup which failed to start is retried within the same visit.
  151. if (is_file($tick) && time() - (int)filemtime($tick) < 900) {
  152. return;
  153. }
  154. if (!manage_available() || manage_due() === []) {
  155. return;
  156. }
  157. @touch($tick);
  158. if (!function_exists('fastcgi_finish_request')) {
  159. self_dispatch(manage_worker_url(), 200);
  160. return;
  161. }
  162. @fastcgi_finish_request();
  163. if (self_dispatch(manage_worker_url(), 2000)) {
  164. return;
  165. }
  166. // No self-dispatch on this host. The operator already has the page, so this
  167. // process does the work itself.
  168. if (session_status() === PHP_SESSION_ACTIVE) {
  169. session_write_close();
  170. }
  171. @set_time_limit(0);
  172. manage_run_due();
  173. }