migrate.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. <?php
  2. /**
  3. * One-time data migrations, run by hand from admin/migrate.php after an update.
  4. *
  5. * Why this exists
  6. * ---------------
  7. * The gallery files have always evolved by lazy defaults: a new field is read
  8. * as `$gallery['x'] ?? default`, so a file written by an older version simply
  9. * keeps working and gains the field the next time something writes it. That is
  10. * still true — nothing here is required for the application to run.
  11. *
  12. * What it buys is a place to *finish* a schema change rather than leaving every
  13. * gallery in one of two shapes indefinitely: files get normalised in one pass,
  14. * broken leftovers are cleaned up, and each gallery records how far it has been
  15. * brought. The next schema change adds a numbered step to MIGRATIONS instead of
  16. * inventing a mechanism.
  17. *
  18. * Rules for a step
  19. * ----------------
  20. * - idempotent: running it twice must be indistinguishable from running it
  21. * once, because nothing stops an operator from pressing the button again
  22. * - written through json_update(), so it cannot fight an upload that lands
  23. * while it runs
  24. * - never destructive: a step normalises and repairs, it does not delete
  25. * images or S3 objects
  26. */
  27. declare(strict_types=1);
  28. /** The schema version a fully migrated gallery file carries. */
  29. const SCHEMA_VERSION = 1;
  30. /** Version number => the function that brings a gallery up to it. */
  31. const MIGRATIONS = [
  32. 1 => 'migrate_v1_topics',
  33. ];
  34. /** How far this gallery has been migrated. Files predating this read as 0. */
  35. function migrate_version_of(array $gallery): int
  36. {
  37. return (int)($gallery['schema_version'] ?? 0);
  38. }
  39. /** The steps a gallery still needs, in order. */
  40. function migrate_pending_for(array $gallery): array
  41. {
  42. $at = migrate_version_of($gallery);
  43. return array_values(array_filter(array_keys(MIGRATIONS), fn(int $v): bool => $v > $at));
  44. }
  45. /**
  46. * Every gallery with something left to do: slug => [title, versions].
  47. * An empty result means the installation is fully migrated.
  48. */
  49. function migrate_pending(): array
  50. {
  51. $out = [];
  52. foreach (galleries_all() as $gallery) {
  53. $pending = migrate_pending_for($gallery);
  54. if ($pending !== []) {
  55. $out[(string)$gallery['slug']] = [
  56. 'title' => (string)($gallery['title'] ?? $gallery['slug']),
  57. 'versions' => $pending,
  58. ];
  59. }
  60. }
  61. return $out;
  62. }
  63. /**
  64. * Run every outstanding step against every gallery.
  65. *
  66. * Each gallery is migrated under its own lock and committed on its own, so a
  67. * failure part-way through leaves the galleries already done in their new shape
  68. * and the rest exactly as they were — never a half-written file.
  69. *
  70. * Returns one row per gallery: ['slug', 'title', 'applied' => [versions],
  71. * 'notes' => [string], 'error' => ?string].
  72. */
  73. function migrate_run(): array
  74. {
  75. $rows = [];
  76. foreach (galleries_all() as $gallery) {
  77. $slug = (string)$gallery['slug'];
  78. $row = ['slug' => $slug, 'title' => (string)($gallery['title'] ?? $slug),
  79. 'applied' => [], 'notes' => [], 'error' => null];
  80. try {
  81. json_update(gallery_file($slug), function (array $g) use (&$row): ?array {
  82. if ($g === []) {
  83. return null; // deleted between the listing and now
  84. }
  85. $pending = migrate_pending_for($g);
  86. if ($pending === []) {
  87. return null;
  88. }
  89. foreach ($pending as $version) {
  90. $g = (MIGRATIONS[$version])($g, $row['notes']);
  91. $g['schema_version'] = $version;
  92. $row['applied'][] = $version;
  93. }
  94. return $g;
  95. });
  96. } catch (Throwable $e) {
  97. $row['error'] = $e->getMessage();
  98. }
  99. $rows[] = $row;
  100. }
  101. return $rows;
  102. }
  103. /**
  104. * v1 — topics.
  105. *
  106. * Topics are additive, so there is nothing to convert: a gallery written before
  107. * they existed is already valid and renders as one untopiced section. This step
  108. * only tidies:
  109. *
  110. * - gives every gallery a 'topics' key, so the field is present rather than
  111. * merely defaulted on read
  112. * - drops malformed topic entries (a hand-edited file, an interrupted write)
  113. * - clears an image's 'topic' when it names a topic that does not exist —
  114. * harmless at render time, where it reads as no topic, but not worth
  115. * carrying around
  116. *
  117. * It also flags what it deliberately cannot do: guest uploads made before this
  118. * version are indistinguishable from the photographer's own, because the image
  119. * record never recorded who uploaded it. They stay untopiced; only uploads made
  120. * from now on land in the guest topic.
  121. *
  122. * @param string[] $notes collects operator-facing remarks about this gallery
  123. */
  124. function migrate_v1_topics(array $gallery, array &$notes): array
  125. {
  126. $before = count($gallery['topics'] ?? []);
  127. $topics = gallery_topics($gallery);
  128. $gallery['topics'] = $topics;
  129. if ($before > count($topics)) {
  130. $notes[] = ($before - count($topics)) . ' malformed topic entr'
  131. . ($before - count($topics) === 1 ? 'y' : 'ies') . ' dropped.';
  132. }
  133. $known = gallery_topic_map($gallery);
  134. $cleared = 0;
  135. foreach ($gallery['images'] ?? [] as $i => $image) {
  136. $topic = (string)($image['topic'] ?? '');
  137. if ($topic !== '' && !isset($known[$topic])) {
  138. unset($gallery['images'][$i]['topic']);
  139. $cleared++;
  140. }
  141. }
  142. if ($cleared > 0) {
  143. $notes[] = "$cleared image(s) pointed at a topic that no longer exists; they are now without a topic.";
  144. }
  145. if (!empty($gallery['upload_key'])) {
  146. $notes[] = 'Guest uploads are enabled. Photos guests uploaded before this update '
  147. . 'cannot be identified afterwards and stay without a topic; new guest uploads '
  148. . 'go into "' . GALLERY_GUEST_TOPIC_NAME . '".';
  149. }
  150. return $gallery;
  151. }