gallery-edit.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <?php
  2. /**
  3. * Per-gallery editor: settings, share link, proxied bulk uploader
  4. * (browser → webhost → S3), and image removal.
  5. */
  6. require dirname(__DIR__) . '/app/bootstrap.php';
  7. auth_require();
  8. $gallery = gallery_load((string)($_GET['g'] ?? ''));
  9. if ($gallery === null) {
  10. flash_set('Gallery not found.', 'error');
  11. redirect('galleries.php');
  12. }
  13. $slug = $gallery['slug'];
  14. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  15. csrf_verify();
  16. $action = $_POST['action'] ?? '';
  17. if ($action === 'settings') {
  18. $gallery['title'] = trim((string)($_POST['title'] ?? '')) ?: $gallery['title'];
  19. $gallery['expires_at'] = trim((string)($_POST['expires_at'] ?? '')) ?: null;
  20. // Unlike the fields above there is no keep-current fallback: the select
  21. // always posts, and an empty value genuinely means "back to Original".
  22. $gallery['max_resolution'] = parse_max_resolution($_POST);
  23. if (!empty($_POST['remove_password'])) {
  24. $gallery['password_hash'] = null;
  25. } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
  26. $gallery['password_hash'] = password_hash($pw, PASSWORD_DEFAULT);
  27. }
  28. gallery_save($gallery);
  29. flash_set('Gallery settings saved.');
  30. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  31. }
  32. if ($action === 'uploads') {
  33. $mode = (string)($_POST['mode'] ?? '');
  34. if ($mode === 'enable' && empty($gallery['upload_key'])) {
  35. $gallery['upload_key'] = random_token(24);
  36. gallery_save($gallery);
  37. flash_set('Guest uploads enabled.');
  38. } elseif ($mode === 'regenerate') {
  39. $gallery['upload_key'] = random_token(24);
  40. gallery_save($gallery);
  41. flash_set('New upload link generated; the old link no longer works.');
  42. } elseif ($mode === 'disable') {
  43. $gallery['upload_key'] = null;
  44. gallery_save($gallery);
  45. flash_set('Guest uploads disabled.');
  46. }
  47. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  48. }
  49. if ($action === 'downloads') {
  50. $gallery['downloads_enabled'] = !empty($_POST['enabled']);
  51. gallery_save($gallery);
  52. if ($gallery['downloads_enabled']) {
  53. // Queue the first build; the background worker picks it up once the
  54. // gallery has been quiet for archive.settle_seconds.
  55. archive_mark_dirty($slug, $gallery);
  56. flash_set('Downloads enabled. The archive will be built in the background.');
  57. } else {
  58. archive_delete($slug);
  59. flash_set('Downloads disabled and the archive removed.');
  60. }
  61. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  62. }
  63. if ($action === 'delete-image') {
  64. $key = (string)($_POST['key'] ?? '');
  65. foreach ($gallery['images'] ?? [] as $i => $img) {
  66. if (($img['key'] ?? '') === $key) {
  67. s3_delete($img['key']);
  68. if (!empty($img['thumb'])) {
  69. s3_delete($img['thumb']);
  70. }
  71. array_splice($gallery['images'], $i, 1);
  72. gallery_save($gallery);
  73. // The archive no longer matches the gallery's contents.
  74. archive_mark_dirty($slug, $gallery);
  75. flash_set('Image deleted.');
  76. break;
  77. }
  78. }
  79. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  80. }
  81. }
  82. // Build the share link from the page the admin is currently on, not from config,
  83. // so it matches whatever host/path this app is actually served under.
  84. $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
  85. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  86. // The site root is one directory up from admin/ (cf. the "../gallery/" link below).
  87. $basePath = str_replace('\\', '/', dirname(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/gallery-edit.php')));
  88. $basePath = rtrim($basePath, '/');
  89. $shareUrl = $scheme . '://' . $host . $basePath . '/gallery/?g=' . rawurlencode($slug);
  90. // Guest upload link (only when a key is set). Same host/path derivation as above.
  91. $uploadUrl = !empty($gallery['upload_key'])
  92. ? $scheme . '://' . $host . $basePath . '/upload.php?g=' . rawurlencode($slug)
  93. . '&k=' . rawurlencode($gallery['upload_key'])
  94. : '';
  95. admin_header($gallery['title'], 'galleries');
  96. flash_render();
  97. ?>
  98. <h1><?= e($gallery['title']) ?></h1>
  99. <p class="help" style="margin-bottom:1.5rem">
  100. Share link: <a href="../gallery/?g=<?= e(rawurlencode($slug)) ?>" target="_blank" rel="noopener"><?= e($shareUrl) ?></a>
  101. </p>
  102. <div class="card">
  103. <h2 style="margin-top:0">Upload images</h2>
  104. <div class="dropzone" id="dropzone"
  105. data-api="api.php"
  106. data-slug="<?= e($slug) ?>"
  107. data-csrf="<?= e(csrf_token()) ?>"
  108. data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
  109. data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>"
  110. data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>"
  111. data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
  112. data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
  113. Drop images here or click to select.<br>
  114. <small>
  115. <?php if (isset($gallery['max_resolution'])): ?>
  116. Uploaded through the site to S3, downscaled to <?= (int)$gallery['max_resolution'] ?> px on the longest edge.
  117. <?php else: ?>
  118. Uploaded through the site to S3, in full resolution, unmodified.
  119. <?php endif; ?>
  120. </small>
  121. </div>
  122. <input type="file" id="file-input" accept="image/*" multiple style="display:none">
  123. <div class="upload-list" id="upload-list"></div>
  124. </div>
  125. <div class="card">
  126. <h2 style="margin-top:0">Guest uploads</h2>
  127. <?php if ($uploadUrl !== ''): ?>
  128. <p class="help" style="margin-bottom:1rem">
  129. Share this link so guests can upload into this gallery without an admin
  130. account. It is key-protected and honors the gallery's password and
  131. expiry, if set.
  132. </p>
  133. <p style="margin-bottom:1rem">
  134. <a href="<?= e($uploadUrl) ?>" target="_blank" rel="noopener"><?= e($uploadUrl) ?></a>
  135. </p>
  136. <form method="post" style="display:inline"
  137. onsubmit="return confirm('Generate a new link? The current link will stop working.')">
  138. <?= csrf_field() ?>
  139. <input type="hidden" name="action" value="uploads">
  140. <input type="hidden" name="mode" value="regenerate">
  141. <button style="margin:0">Regenerate link</button>
  142. </form>
  143. <form method="post" style="display:inline"
  144. onsubmit="return confirm('Disable guest uploads? The link will stop working.')">
  145. <?= csrf_field() ?>
  146. <input type="hidden" name="action" value="uploads">
  147. <input type="hidden" name="mode" value="disable">
  148. <button class="btn-danger" style="margin:0">Disable</button>
  149. </form>
  150. <?php else: ?>
  151. <p class="help" style="margin-bottom:1rem">
  152. Guest uploads are disabled. Enable them to get a shareable link that
  153. lets people upload into this gallery without an admin account.
  154. </p>
  155. <form method="post">
  156. <?= csrf_field() ?>
  157. <input type="hidden" name="action" value="uploads">
  158. <input type="hidden" name="mode" value="enable">
  159. <button style="margin:0">Enable guest uploads</button>
  160. </form>
  161. <?php endif; ?>
  162. </div>
  163. <?php $status = archive_status($gallery); ?>
  164. <div class="card" id="archive-card"
  165. data-api="archive-api.php"
  166. data-slug="<?= e($slug) ?>"
  167. data-csrf="<?= e(csrf_token()) ?>">
  168. <h2 style="margin-top:0">Download all</h2>
  169. <p class="help" style="margin-bottom:1rem">
  170. Offers visitors a single ZIP of every photo. It is assembled once and
  171. stored on S3, so the download itself never runs through this webhost —
  172. and it is rebuilt automatically whenever images are added or removed.
  173. While a rebuild is pending the button on the gallery is disabled, so
  174. nobody receives an archive that is missing the newest photos.
  175. </p>
  176. <form method="post" style="margin-bottom:1rem">
  177. <?= csrf_field() ?>
  178. <input type="hidden" name="action" value="downloads">
  179. <input type="hidden" name="enabled" value="<?= empty($gallery['downloads_enabled']) ? '1' : '0' ?>">
  180. <?php if (empty($gallery['downloads_enabled'])): ?>
  181. <button style="margin:0">Enable downloads</button>
  182. <?php else: ?>
  183. <button class="btn-danger" style="margin:0"
  184. onclick="return confirm('Disable downloads and delete the archive from S3?')">
  185. Disable downloads
  186. </button>
  187. <?php endif; ?>
  188. </form>
  189. <?php if (!empty($gallery['downloads_enabled'])): ?>
  190. <p id="archive-status" class="help" style="margin-bottom:.6rem">
  191. <?php if ($status['archive'] && !$status['stale']): ?>
  192. Ready · <?= e(human_bytes((int)$status['archive']['size'])) ?>
  193. · <?= (int)$status['archive']['count'] ?> photos
  194. · built <?= e($status['archive']['built_at']) ?>
  195. <?php elseif ($status['building']): ?>
  196. Building — <?= (int)$status['done'] ?> of <?= (int)$status['total'] ?> photos done.
  197. <?php elseif ($status['queued'] && $status['due_in'] > 0): ?>
  198. Queued — the rebuild starts in about <?= (int)ceil($status['due_in'] / 60) ?> min.
  199. <?php elseif ($status['queued']): ?>
  200. Queued — the rebuild starts shortly.
  201. <?php elseif ($status['archive']): ?>
  202. Out of date — the archive does not match the current images.
  203. <?php else: ?>
  204. No archive yet.
  205. <?php endif; ?>
  206. </p>
  207. <div id="archive-bar" class="archive-bar" hidden><span></span></div>
  208. <button id="archive-build" style="margin:0"
  209. <?= $status['building'] ? 'data-resume="1"' : '' ?>>
  210. <?= $status['building'] ? 'Resume build' : ($status['archive'] ? 'Rebuild now' : 'Build now') ?>
  211. </button>
  212. <button id="archive-cancel" class="btn-ghost" style="margin:0" hidden>Cancel</button>
  213. <script src="../assets/archive.js" defer></script>
  214. <?php endif; ?>
  215. </div>
  216. <form method="post" class="card">
  217. <?= csrf_field() ?>
  218. <input type="hidden" name="action" value="settings">
  219. <h2 style="margin-top:0">Settings</h2>
  220. <label for="t">Title</label>
  221. <input type="text" id="t" name="title" value="<?= e($gallery['title']) ?>">
  222. <label for="ex">Expiry date (blank = never)</label>
  223. <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
  224. <?php resolution_field(isset($gallery['max_resolution']) ? (int)$gallery['max_resolution'] : null) ?>
  225. <label for="p">Set new password (blank = keep current)</label>
  226. <input type="text" id="p" name="password" autocomplete="off">
  227. <?php if (!empty($gallery['password_hash'])): ?>
  228. <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
  229. <input type="checkbox" name="remove_password" value="1"> Remove password protection
  230. </label></p>
  231. <?php endif; ?>
  232. <button type="submit">Save settings</button>
  233. </form>
  234. <h2>Images (<span id="img-count"><?= count($gallery['images'] ?? []) ?></span>)</h2>
  235. <div class="thumb-row">
  236. <?php foreach ($gallery['images'] ?? [] as $img): ?>
  237. <figure>
  238. <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>" alt="" loading="lazy">
  239. <figcaption title="<?= e($img['name'] ?? '') ?>"><?= e($img['name'] ?? '') ?></figcaption>
  240. <form method="post" onsubmit="return confirm('Delete this image from S3?')">
  241. <?= csrf_field() ?>
  242. <input type="hidden" name="action" value="delete-image">
  243. <input type="hidden" name="key" value="<?= e($img['key']) ?>">
  244. <button>✕</button>
  245. </form>
  246. </figure>
  247. <?php endforeach; ?>
  248. </div>
  249. <script src="../assets/admin.js"></script>
  250. <?php admin_footer(); ?>