archive.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. <?php
  2. /**
  3. * Gallery ZIP archives: building them, and keeping them up to date.
  4. *
  5. * Why it works this way
  6. * --------------------
  7. * A gallery can hold hundreds of 8 MB originals, and the host caps
  8. * max_execution_time at 60 s. Streaming a multi-gigabyte ZIP through PHP would
  9. * need a request that stays alive for the whole download, so instead the archive
  10. * is *built once into S3* and visitors are redirected to a presigned URL for it.
  11. * The download then never touches the webhost at all — it is a plain S3 GET,
  12. * resumable and immune to any server timeout.
  13. *
  14. * Building it is split into slices. archive_run_slice() copies as many photos as
  15. * fit in a time budget and returns; progress is committed to a state file after
  16. * every photo, so "build the archive" is just "run slices until finished". No
  17. * single request ever approaches the 60 s cap, and nothing depends on
  18. * set_time_limit() being allowed.
  19. *
  20. * Each photo is streamed S3 → buffer file → S3: the ZIP needs a local header
  21. * immediately before the file's bytes, and multipart parts are atomic, so the
  22. * bytes cannot be copied server-side with UploadPartCopy. The buffer file
  23. * accumulates until it passes the 5 MB multipart minimum, then becomes one part.
  24. *
  25. * Interruptions
  26. * -------------
  27. * State is written with json_write() (tmp file + rename), so it is never half
  28. * written — at worst it is one slice old and that slice replays. The rest is
  29. * handled by ordering:
  30. *
  31. * - mid photo → every slice starts by truncating the buffer back to the last
  32. * committed length, so a partial tail is dropped without
  33. * needing the failure to have been caught.
  34. * - mid part → the ETag is committed to state only after S3 accepts the
  35. * part, and the buffer is truncated only after that. A crash
  36. * anywhere in between re-uploads the same part number, which
  37. * S3 accepts until the upload is completed.
  38. * - mid finish → a retried CompleteMultipartUpload returns NoSuchUpload once
  39. * it has already succeeded; that is treated as done after a
  40. * HEAD confirms the object exists.
  41. * - abandoned → the queue entry survives on disk, and a build with no
  42. * progress for archive.abandon_hours is aborted and restarted.
  43. *
  44. * Staying current
  45. * ---------------
  46. * Every stored or deleted image marks its gallery dirty (archive_mark_dirty).
  47. * A dirty gallery's download button is disabled until the rebuild lands, rather
  48. * than handing out a ZIP that is missing the newest photos. Rebuilds run in the
  49. * background: archive_kick() on a page render dispatches worker.php, which runs
  50. * a slice and then dispatches its own successor, so one upload starts a chain
  51. * that finishes unattended. See docs/ARCHITECTURE.md.
  52. */
  53. declare(strict_types=1);
  54. /** Queue of galleries whose archive no longer matches their contents. */
  55. function archive_queue_file(): string
  56. {
  57. return DATA_DIR . '/archive-queue.json';
  58. }
  59. /** Site-wide lock: exactly one archive worker runs at a time. */
  60. function archive_lock_file(): string
  61. {
  62. return DATA_DIR . '/archive.lock';
  63. }
  64. /** Throttle marker so a busy site does not dispatch a worker per page view. */
  65. function archive_tick_file(): string
  66. {
  67. return DATA_DIR . '/archive.tick';
  68. }
  69. /**
  70. * Identity of a gallery's image set. A build records the hash it was made from;
  71. * when the gallery's current hash differs, the archive is out of date.
  72. */
  73. function archive_source_hash(array $gallery): string
  74. {
  75. return sha1(implode("\n", array_column($gallery['images'] ?? [], 'key')));
  76. }
  77. /** Whether a gallery's archive is missing or no longer matches its images. */
  78. function archive_is_stale(array $gallery): bool
  79. {
  80. $archive = $gallery['archive'] ?? null;
  81. if (empty($archive['key'])) {
  82. return true;
  83. }
  84. return ($archive['source_hash'] ?? '') !== archive_source_hash($gallery);
  85. }
  86. // ---------------------------------------------------------------------------
  87. // The queue
  88. // ---------------------------------------------------------------------------
  89. /**
  90. * Flag a gallery for rebuilding. Called from gallery_append_image() and from
  91. * image deletion, i.e. everywhere a gallery's contents can change.
  92. *
  93. * Uploads run in parallel (uploads.concurrency), so the queue is written through
  94. * json_update()'s exclusive lock — three uploads finishing together must not
  95. * drop each other's entries. Galleries without downloads enabled are skipped:
  96. * queueing them would give the worker chain work it can never finish.
  97. */
  98. function archive_mark_dirty(string $slug, ?array $gallery = null): void
  99. {
  100. $gallery ??= gallery_load($slug);
  101. if ($gallery === null || empty($gallery['downloads_enabled'])) {
  102. return;
  103. }
  104. json_update(archive_queue_file(), function (array $queue) use ($slug): array {
  105. $queue['galleries'][$slug] = time();
  106. return $queue;
  107. });
  108. }
  109. /** Drop a gallery from the queue (rebuilt, deleted, or downloads turned off). */
  110. function archive_unqueue(string $slug): void
  111. {
  112. json_update(archive_queue_file(), function (array $queue) use ($slug): ?array {
  113. if (!isset($queue['galleries'][$slug])) {
  114. return null; // nothing to do; leave the file untouched
  115. }
  116. unset($queue['galleries'][$slug]);
  117. return $queue;
  118. });
  119. }
  120. /**
  121. * The next gallery due for a rebuild, or null if none has settled yet.
  122. *
  123. * A gallery is only rebuilt once it has been quiet for archive.settle_seconds.
  124. * Thirty wedding guests uploading over an hour must not restart the build thirty
  125. * times; the delay batches an upload burst into a single rebuild.
  126. */
  127. function archive_next_due(): ?string
  128. {
  129. $queue = json_read(archive_queue_file());
  130. $settle = (int)config('archive.settle_seconds', 300);
  131. foreach ($queue['galleries'] ?? [] as $slug => $dirtyAt) {
  132. if (time() - (int)$dirtyAt >= $settle) {
  133. return (string)$slug;
  134. }
  135. }
  136. return null;
  137. }
  138. /**
  139. * Seconds until the earliest queued gallery settles: 0 if one is due now, null
  140. * if the queue is empty. The worker uses this to decide between doing a slice
  141. * and waiting out the settle window.
  142. */
  143. function archive_queue_wait(): ?int
  144. {
  145. $queue = json_read(archive_queue_file());
  146. $settle = (int)config('archive.settle_seconds', 300);
  147. $wait = null;
  148. foreach ($queue['galleries'] ?? [] as $dirtyAt) {
  149. $due = max(0, $settle - (time() - (int)$dirtyAt));
  150. $wait = $wait === null ? $due : min($wait, $due);
  151. }
  152. return $wait;
  153. }
  154. // ---------------------------------------------------------------------------
  155. // Build state
  156. // ---------------------------------------------------------------------------
  157. /**
  158. * Start a fresh build: abandon anything in flight, open a new multipart upload,
  159. * and write the initial state. Returns the state, or null if it cannot start.
  160. *
  161. * Content-Type and Content-Disposition are set on the multipart create, so S3
  162. * stores them as the finished object's metadata and the presigned URL downloads
  163. * as a properly named .zip with no extra signing work.
  164. */
  165. function archive_start(string $slug, array $gallery): ?array
  166. {
  167. if (empty($gallery['images'])) {
  168. return null; // nothing to archive
  169. }
  170. archive_abort($slug);
  171. $filename = safe_filename(($gallery['title'] ?: $slug) . '.zip', $slug);
  172. $key = s3_gallery_prefix($slug) . '/archive/' . random_token(6) . '-' . $filename;
  173. $uploadId = s3_mpu_create($key, 'application/zip', 'attachment; filename="' . $filename . '"');
  174. if ($uploadId === null) {
  175. return null;
  176. }
  177. $state = [
  178. 'key' => $key,
  179. 'upload_id' => $uploadId,
  180. 'source_hash' => archive_source_hash($gallery),
  181. 'total' => count($gallery['images']),
  182. 'next_index' => 0,
  183. // Archive length so far, and how much of it S3 already has. The
  184. // difference is exactly what the buffer file holds.
  185. 'offset' => 0,
  186. 'uploaded' => 0,
  187. 'part_number' => 1,
  188. 'parts' => [],
  189. 'entries' => [],
  190. 'names' => [], // filename dedupe map, carried across slices
  191. 'slowest' => 5.0, // seconds; grows to the slowest photo seen
  192. 'started_at' => time(),
  193. ];
  194. json_write(gallery_archive_file($slug), $state);
  195. @unlink(gallery_archive_buffer($slug));
  196. return $state;
  197. }
  198. /**
  199. * Abandon an in-flight build. Aborting the multipart upload matters: S3 keeps
  200. * (and bills for) the parts of an incomplete upload indefinitely.
  201. */
  202. function archive_abort(string $slug): void
  203. {
  204. $state = json_read(gallery_archive_file($slug));
  205. if (!empty($state['upload_id']) && !empty($state['key'])) {
  206. s3_mpu_abort((string)$state['key'], (string)$state['upload_id']);
  207. }
  208. @unlink(gallery_archive_file($slug));
  209. @unlink(gallery_archive_buffer($slug));
  210. }
  211. /** Delete a gallery's finished archive from S3 and forget it. */
  212. function archive_delete(string $slug): void
  213. {
  214. archive_abort($slug);
  215. $gallery = gallery_load($slug);
  216. if ($gallery !== null && !empty($gallery['archive']['key'])) {
  217. s3_delete((string)$gallery['archive']['key']);
  218. }
  219. json_update(gallery_file($slug), function (array $g): ?array {
  220. if ($g === [] || !isset($g['archive'])) {
  221. return null;
  222. }
  223. unset($g['archive']);
  224. return $g;
  225. });
  226. archive_unqueue($slug);
  227. }
  228. // ---------------------------------------------------------------------------
  229. // The slice
  230. // ---------------------------------------------------------------------------
  231. /**
  232. * Copy as many of a gallery's photos into its archive as fit in $budget seconds.
  233. *
  234. * Returns ['done' => int, 'total' => int, 'finished' => bool, 'size' => ?int,
  235. * 'error' => ?string]. Call again to continue; all progress is on disk.
  236. */
  237. function archive_run_slice(string $slug, ?float $budget = null): array
  238. {
  239. $started = microtime(true);
  240. $budget ??= (float)config('archive.step_seconds', 25);
  241. $gallery = gallery_load($slug);
  242. if ($gallery === null) {
  243. archive_unqueue($slug);
  244. return archive_result(0, 0, false, null, 'Gallery no longer exists');
  245. }
  246. $stateFile = gallery_archive_file($slug);
  247. $state = json_read($stateFile);
  248. $abandoned = $state !== []
  249. && time() - (int)($state['started_at'] ?? 0) > (int)config('archive.abandon_hours', 24) * 3600;
  250. // Restart whenever the gallery has changed under an in-flight build, or the
  251. // build has been stalled long enough to be considered dead.
  252. if ($state === [] || $abandoned || ($state['source_hash'] ?? '') !== archive_source_hash($gallery)) {
  253. $state = archive_start($slug, $gallery);
  254. if ($state === null) {
  255. archive_unqueue($slug);
  256. return archive_result(0, 0, false, null, 'Cannot start archive (empty gallery or S3 refused)');
  257. }
  258. }
  259. $images = $gallery['images'];
  260. $bufferPath = gallery_archive_buffer($slug);
  261. $fh = fopen($bufferPath, 'c+b');
  262. if ($fh === false) {
  263. return archive_result((int)$state['next_index'], (int)$state['total'], false, null, 'Cannot open archive buffer');
  264. }
  265. flock($fh, LOCK_EX);
  266. // Recovery: drop anything written past the last committed position. Doing
  267. // this unconditionally means a hard kill needs no cleanup of its own.
  268. ftruncate($fh, (int)$state['offset'] - (int)$state['uploaded']);
  269. fseek($fh, 0, SEEK_END);
  270. $partMin = (int)config('archive.part_min_bytes', 5 * 1024 * 1024);
  271. $mtime = strtotime((string)($gallery['created_at'] ?? '')) ?: time();
  272. $error = null;
  273. $processed = 0;
  274. while ($state['next_index'] < $state['total']) {
  275. // Check the clock before starting a photo, never during one, and leave
  276. // room for one that runs as long as the slowest seen so far.
  277. //
  278. // Always do at least one photo, whatever the estimate says. A gallery
  279. // whose photos each take longer than the whole budget must still creep
  280. // forward one photo per slice; refusing to start would leave the build
  281. // stuck for ever, which is far worse than a slice that overruns.
  282. if ($processed > 0 && microtime(true) - $started + $state['slowest'] * 1.5 >= $budget) {
  283. break;
  284. }
  285. $photoStart = microtime(true);
  286. $image = $images[$state['next_index']];
  287. // Reserve the name in a copy: a photo that fails below is retried by the
  288. // next slice, and a name left registered by the failed attempt would
  289. // make the retry rename itself to "… (2)".
  290. $names = $state['names'];
  291. $name = zip_dedupe_name(
  292. safe_filename((string)($image['name'] ?? 'photo.jpg'), 'photo'),
  293. $names
  294. );
  295. $entryOffset = (int)$state['offset'];
  296. $header = zip_local_header($name, $mtime);
  297. fwrite($fh, $header);
  298. // One pass: bytes go to the buffer and through the CRC at the same time,
  299. // so an original never has to be held in memory or read back.
  300. $crcContext = hash_init('crc32b');
  301. [$status, $bytes] = s3_get_stream((string)$image['key'], function (string $chunk) use ($fh, $crcContext): void {
  302. hash_update($crcContext, $chunk);
  303. fwrite($fh, $chunk);
  304. });
  305. if ($status < 200 || $status >= 300) {
  306. // Undo this photo entirely and stop; the next slice retries it.
  307. ftruncate($fh, $entryOffset - (int)$state['uploaded']);
  308. fseek($fh, 0, SEEK_END);
  309. $error = 'S3 returned HTTP ' . $status . ' for ' . ($image['name'] ?? $image['key']);
  310. break;
  311. }
  312. // The real size and CRC are only known now, so patch them into the
  313. // header written above. Using the streamed byte count rather than the
  314. // recorded one also heals a gallery whose stored size was ever wrong.
  315. $crc = (int)hexdec(hash_final($crcContext));
  316. zip_patch_local_header($fh, $entryOffset - (int)$state['uploaded'], $name, $crc, $bytes);
  317. $state['names'] = $names;
  318. $state['entries'][] = [
  319. 'name' => $name,
  320. 'crc' => $crc,
  321. 'size' => $bytes,
  322. 'offset' => $entryOffset,
  323. 'mtime' => $mtime,
  324. ];
  325. $state['offset'] = $entryOffset + strlen($header) + $bytes;
  326. $state['next_index']++;
  327. $state['slowest'] = max((float)$state['slowest'], microtime(true) - $photoStart);
  328. $processed++;
  329. if ((int)$state['offset'] - (int)$state['uploaded'] >= $partMin) {
  330. $error = archive_flush_part($state, $fh, $bufferPath, false);
  331. if ($error !== null) {
  332. break;
  333. }
  334. }
  335. json_write($stateFile, $state);
  336. }
  337. // Everything copied: append the central directory and close the upload.
  338. $finished = false;
  339. if ($error === null && $state['next_index'] >= $state['total']) {
  340. [$finished, $error] = archive_finish($slug, $state, $fh, $bufferPath);
  341. }
  342. json_write($stateFile, $state);
  343. flock($fh, LOCK_UN);
  344. fclose($fh);
  345. if ($finished) {
  346. @unlink($stateFile);
  347. @unlink($bufferPath);
  348. }
  349. return archive_result(
  350. (int)$state['next_index'],
  351. (int)$state['total'],
  352. $finished,
  353. $finished ? (int)$state['offset'] : null,
  354. $error
  355. );
  356. }
  357. /**
  358. * Send the buffered bytes to S3 as the next multipart part.
  359. *
  360. * The order here is what makes an interrupted build safe: S3 accepts the part,
  361. * then the ETag is committed to state, and only then is the buffer cleared. A
  362. * crash before the commit re-uploads the same part number with the same bytes,
  363. * which S3 allows until the upload is completed.
  364. *
  365. * Returns an error message, or null on success.
  366. *
  367. * @param resource $fh
  368. */
  369. function archive_flush_part(array &$state, $fh, string $bufferPath, bool $isLast): ?string
  370. {
  371. fflush($fh);
  372. if (!$isLast && (int)$state['offset'] - (int)$state['uploaded'] === 0) {
  373. return null; // nothing pending
  374. }
  375. $etag = s3_mpu_upload_part(
  376. (string)$state['key'],
  377. (string)$state['upload_id'],
  378. (int)$state['part_number'],
  379. $bufferPath
  380. );
  381. if ($etag === null) {
  382. return 'S3 rejected part ' . $state['part_number'];
  383. }
  384. $state['parts'][] = ['n' => (int)$state['part_number'], 'etag' => $etag];
  385. $state['part_number']++;
  386. $state['uploaded'] = (int)$state['offset'];
  387. ftruncate($fh, 0);
  388. fseek($fh, 0, SEEK_END);
  389. return null;
  390. }
  391. /**
  392. * Write the central directory, upload the last part and complete the multipart
  393. * upload, then record the archive on the gallery.
  394. *
  395. * Returns [finished, error].
  396. *
  397. * @param resource $fh
  398. */
  399. function archive_finish(string $slug, array &$state, $fh, string $bufferPath): array
  400. {
  401. // The central directory is built now, from the real sizes and offsets, so
  402. // it uses ZIP64 fields only where a value actually overflows 32 bits.
  403. $directory = '';
  404. foreach ($state['entries'] as $entry) {
  405. $directory .= zip_central_entry($entry);
  406. }
  407. $trailer = $directory . zip_end_of_central_directory(
  408. count($state['entries']),
  409. strlen($directory),
  410. (int)$state['offset']
  411. );
  412. fwrite($fh, $trailer);
  413. $state['offset'] = (int)$state['offset'] + strlen($trailer);
  414. // The 5 MB minimum does not apply to the final part, which is what lets a
  415. // gallery of small files work with the same buffering scheme.
  416. $error = archive_flush_part($state, $fh, $bufferPath, true);
  417. if ($error !== null) {
  418. return [false, $error];
  419. }
  420. [$ok, $body] = s3_mpu_complete((string)$state['key'], (string)$state['upload_id'], $state['parts']);
  421. if (!$ok) {
  422. // A completed upload no longer exists; if the object is there, an
  423. // earlier attempt succeeded and only the state write was lost.
  424. if (str_contains($body, 'NoSuchUpload') && s3_head((string)$state['key']) !== null) {
  425. $ok = true;
  426. }
  427. }
  428. if (!$ok) {
  429. return [false, 'S3 could not complete the archive upload'];
  430. }
  431. $summary = [
  432. 'key' => (string)$state['key'],
  433. 'size' => (int)$state['offset'],
  434. 'count' => count($state['entries']),
  435. 'built_at' => date('Y-m-d H:i:s'),
  436. 'source_hash' => (string)$state['source_hash'],
  437. ];
  438. // json_update, not gallery_save: an upload finishing right now must not be
  439. // overwritten by a gallery this function read minutes ago.
  440. $previousKey = null;
  441. json_update(gallery_file($slug), function (array $g) use ($summary, &$previousKey): ?array {
  442. if ($g === []) {
  443. return null; // deleted mid-build
  444. }
  445. $previousKey = $g['archive']['key'] ?? null;
  446. $g['archive'] = $summary;
  447. return $g;
  448. });
  449. if ($previousKey !== null && $previousKey !== $summary['key']) {
  450. s3_delete((string)$previousKey);
  451. }
  452. // Leave the gallery queued if it changed while this build was running — the
  453. // archive just written is already out of date and needs another pass.
  454. $current = gallery_load($slug);
  455. if ($current === null || !archive_is_stale($current)) {
  456. archive_unqueue($slug);
  457. }
  458. return [true, null];
  459. }
  460. /** Uniform slice/step result shape, shared by the worker and the admin API. */
  461. function archive_result(int $done, int $total, bool $finished, ?int $size, ?string $error): array
  462. {
  463. return [
  464. 'done' => $done,
  465. 'total' => $total,
  466. 'finished' => $finished,
  467. 'size' => $size,
  468. 'error' => $error,
  469. ];
  470. }
  471. /**
  472. * What the admin page shows for a gallery: whether an archive exists, whether it
  473. * is current, and how far any in-flight build has got.
  474. */
  475. function archive_status(array $gallery): array
  476. {
  477. $slug = (string)$gallery['slug'];
  478. $archive = $gallery['archive'] ?? null;
  479. $state = json_read(gallery_archive_file($slug));
  480. $queue = json_read(archive_queue_file());
  481. return [
  482. 'archive' => $archive,
  483. 'stale' => archive_is_stale($gallery),
  484. 'building' => $state !== [],
  485. 'done' => (int)($state['next_index'] ?? 0),
  486. 'total' => (int)($state['total'] ?? count($gallery['images'] ?? [])),
  487. 'queued' => isset($queue['galleries'][$slug]),
  488. 'due_in' => isset($queue['galleries'][$slug])
  489. ? max(0, (int)config('archive.settle_seconds', 300) - (time() - (int)$queue['galleries'][$slug]))
  490. : null,
  491. ];
  492. }
  493. // ---------------------------------------------------------------------------
  494. // Background execution
  495. // ---------------------------------------------------------------------------
  496. /** Shared secret authenticating the self-dispatched worker requests. */
  497. function archive_worker_key(): string
  498. {
  499. $file = DATA_DIR . '/worker-key.json';
  500. $data = json_read($file);
  501. if (empty($data['key'])) {
  502. $data = ['key' => random_token(32)];
  503. json_write($file, $data);
  504. }
  505. return (string)$data['key'];
  506. }
  507. /**
  508. * URL of worker.php on this installation.
  509. *
  510. * site.base_url is preferred when it has been filled in, because it does not
  511. * depend on the request's Host header. Otherwise the URL is derived from the
  512. * current request, mapping APP_ROOT against DOCUMENT_ROOT so an app installed in
  513. * a subdirectory still resolves.
  514. */
  515. function archive_worker_url(): ?string
  516. {
  517. $query = '/worker.php?key=' . rawurlencode(archive_worker_key());
  518. $configured = rtrim((string)config('site.base_url', ''), '/');
  519. if ($configured !== '' && !str_contains($configured, 'example.com')) {
  520. return $configured . $query;
  521. }
  522. $host = (string)($_SERVER['HTTP_HOST'] ?? '');
  523. $root = rtrim(str_replace('\\', '/', (string)($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
  524. $app = str_replace('\\', '/', APP_ROOT);
  525. if ($host === '' || $root === '' || !str_starts_with($app, $root)) {
  526. return null;
  527. }
  528. $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
  529. return $scheme . '://' . $host . rtrim(substr($app, strlen($root)), '/') . $query;
  530. }
  531. /**
  532. * Fire a request at worker.php and hang up without waiting for it. The worker
  533. * sets ignore_user_abort(), so it runs its slice regardless.
  534. *
  535. * A timeout is the expected, successful outcome here — it means the request was
  536. * delivered and the worker is busy with it. A *completed* response is only a
  537. * success if it is the worker's own 204; anything else (a 404 from a wrong key
  538. * or a misconfigured base URL) means nothing is running, and saying so lets the
  539. * caller fall back to doing the work inline instead of silently stalling.
  540. */
  541. function archive_dispatch(int $timeoutMs = 1000): bool
  542. {
  543. $url = archive_worker_url();
  544. if ($url === null || !function_exists('curl_init')) {
  545. return false;
  546. }
  547. $ch = curl_init($url);
  548. curl_setopt_array($ch, [
  549. CURLOPT_RETURNTRANSFER => true,
  550. CURLOPT_NOSIGNAL => true,
  551. CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
  552. CURLOPT_TIMEOUT_MS => $timeoutMs,
  553. ]);
  554. curl_exec($ch);
  555. $errno = curl_errno($ch);
  556. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  557. return $errno === CURLE_OPERATION_TIMEOUTED || ($errno === 0 && $status === 204);
  558. }
  559. /**
  560. * Take the site-wide worker lock, or report that someone else holds it.
  561. * Returns the lock handle (which must stay open for the lock to hold) or null.
  562. *
  563. * $waitSeconds polls rather than blocking outright, so an admin asking for a
  564. * rebuild can wait out a background worker's short settle-sleep instead of
  565. * failing the moment it finds the lock taken — while a background worker, which
  566. * has nothing to wait for, passes 0 and steps aside immediately.
  567. *
  568. * @return resource|null
  569. */
  570. function archive_lock(int $waitSeconds = 0)
  571. {
  572. $fh = fopen(archive_lock_file(), 'c');
  573. if ($fh === false) {
  574. return null;
  575. }
  576. $deadline = time() + $waitSeconds;
  577. do {
  578. if (flock($fh, LOCK_EX | LOCK_NB)) {
  579. return $fh;
  580. }
  581. if (time() < $deadline) {
  582. sleep(1);
  583. }
  584. } while (time() < $deadline);
  585. fclose($fh);
  586. return null;
  587. }
  588. /**
  589. * Run one slice for whichever gallery is due, under the worker lock.
  590. * Returns the slice result, or null if nothing was due or another worker holds
  591. * the lock.
  592. */
  593. function archive_run_due(): ?array
  594. {
  595. $lock = archive_lock();
  596. if ($lock === null) {
  597. return null;
  598. }
  599. try {
  600. $slug = archive_next_due();
  601. return $slug === null ? null : archive_run_slice($slug);
  602. } finally {
  603. flock($lock, LOCK_UN);
  604. fclose($lock);
  605. }
  606. }
  607. /**
  608. * Called at the end of every public page render. Decides, as cheaply as
  609. * possible, whether any background work is pending and gets it moving.
  610. *
  611. * The page is flushed to the visitor before anything slow happens, so neither
  612. * the dispatch nor the inline fallback can delay it. The fallback matters on
  613. * hosts that cannot make an HTTP request to themselves: there, progress needs
  614. * one page view per slice instead of running on its own.
  615. */
  616. function archive_kick(): void
  617. {
  618. $tick = archive_tick_file();
  619. if (is_file($tick) && time() - (int)filemtime($tick) < 30) {
  620. return; // dispatched recently; do not spend anything on this request
  621. }
  622. $queue = json_read(archive_queue_file());
  623. if (empty($queue['galleries'])) {
  624. return;
  625. }
  626. @touch($tick);
  627. if (!function_exists('fastcgi_finish_request')) {
  628. // Cannot detach: keep the delay to the visitor as short as possible.
  629. archive_dispatch(200);
  630. return;
  631. }
  632. @fastcgi_finish_request();
  633. if (archive_dispatch(2000)) {
  634. return;
  635. }
  636. // No self-dispatch on this host. The visitor already has the page, so this
  637. // process can do the work itself.
  638. if (session_status() === PHP_SESSION_ACTIVE) {
  639. session_write_close();
  640. }
  641. archive_run_due();
  642. }