admin.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /*
  2. * Gallery bulk uploader.
  3. *
  4. * Per file, one request:
  5. * 1. draw a small JPEG thumbnail on a canvas (browser-side)
  6. * 2. POST the original + thumbnail to admin/api.php as multipart/form-data
  7. * 3. the webhost streams both to S3 and registers the image
  8. *
  9. * One file per request keeps each PHP process small, so the size of the whole
  10. * gallery upload never matters — only the largest single image. The browser
  11. * never sees an S3 URL or credential for writing.
  12. *
  13. * Several files are in flight at once (data-concurrency, default 3). Each file
  14. * is store-and-forward — the webhost receives the whole body before it starts
  15. * the S3 PUT — so a single-file queue leaves the uplink idle for the entire
  16. * webhost→S3 leg and for every thumbnail decode. Overlapping requests keeps it
  17. * saturated; the server appends to the gallery JSON under a lock, so parallel
  18. * completions cannot lose entries.
  19. */
  20. (function () {
  21. 'use strict';
  22. var zone = document.getElementById('dropzone');
  23. if (!zone) return;
  24. var input = document.getElementById('file-input');
  25. var list = document.getElementById('upload-list');
  26. var countEl = document.getElementById('img-count');
  27. var api = zone.dataset.api;
  28. var slug = zone.dataset.slug;
  29. var csrf = zone.dataset.csrf;
  30. // Guest upload links pass a per-gallery key; the admin edit page sets none.
  31. var uploadKey = zone.dataset.key || '';
  32. var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
  33. var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
  34. // How many uploads may be in flight at once. Small on purpose: each one
  35. // occupies a PHP worker on the webhost for its whole S3 round trip.
  36. var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
  37. // Transient failures (network drop, 5xx, throttling) are retried
  38. // automatically before the row is marked failed.
  39. var maxAttempts = 3;
  40. var queue = [];
  41. var active = 0;
  42. zone.addEventListener('click', function () { input.click(); });
  43. input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
  44. ['dragenter', 'dragover'].forEach(function (ev) {
  45. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
  46. });
  47. ['dragleave', 'drop'].forEach(function (ev) {
  48. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
  49. });
  50. zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
  51. window.addEventListener('beforeunload', function (e) {
  52. if (active || queue.length) { e.preventDefault(); e.returnValue = ''; }
  53. });
  54. function enqueue(files) {
  55. Array.prototype.forEach.call(files, function (file) {
  56. var row = document.createElement('div');
  57. row.className = 'upload-item';
  58. row.innerHTML = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
  59. row.querySelector('.name').textContent = file.name;
  60. list.appendChild(row);
  61. queue.push({ file: file, row: row });
  62. });
  63. pump();
  64. }
  65. /* Start jobs until the parallel slots are full; called again as each ends. */
  66. function pump() {
  67. while (active < maxParallel && queue.length) {
  68. run(queue.shift());
  69. }
  70. }
  71. function run(job) {
  72. active++;
  73. uploadOne(job.file, job.row)
  74. .then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
  75. .catch(function (err) {
  76. setState(job.row, 'failed', 'error');
  77. job.row.title = String(err);
  78. var retry = document.createElement('a');
  79. retry.href = '#';
  80. retry.textContent = ' retry';
  81. retry.addEventListener('click', function (e) {
  82. e.preventDefault();
  83. retry.remove();
  84. setState(job.row, 'queued', '');
  85. queue.push(job);
  86. pump();
  87. });
  88. job.row.appendChild(retry);
  89. })
  90. .finally(function () { active--; pump(); });
  91. }
  92. function setState(row, text, cls) {
  93. var el = row.querySelector('.state');
  94. el.textContent = text;
  95. el.className = 'state ' + (cls || '');
  96. }
  97. function bumpCount() {
  98. if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
  99. }
  100. /* An error worth repeating: the request never landed, or the server said it
  101. was a temporary condition. A 4xx is a real rejection (bad CSRF token,
  102. wrong file type, gone gallery) and must not be retried. */
  103. function failure(message, status) {
  104. var err = new Error(message);
  105. err.transient = status === 0 || status === 408 || status === 429 || status >= 500;
  106. return err;
  107. }
  108. /* POST multipart to the webhost with upload progress (fetch has none → XHR). */
  109. function sendForm(form, onProgress) {
  110. return new Promise(function (resolve, reject) {
  111. var xhr = new XMLHttpRequest();
  112. xhr.open('POST', api);
  113. xhr.setRequestHeader('X-CSRF-Token', csrf);
  114. // Let the browser set Content-Type (with the multipart boundary).
  115. xhr.upload.addEventListener('progress', function (e) {
  116. if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
  117. });
  118. xhr.addEventListener('load', function () {
  119. var data = {};
  120. try { data = JSON.parse(xhr.responseText); } catch (err) {}
  121. if (xhr.status >= 200 && xhr.status < 300 && data.ok) resolve(data);
  122. else reject(failure((data && data.error) || ('Upload failed (' + xhr.status + ')'), xhr.status));
  123. });
  124. xhr.addEventListener('error', function () { reject(failure('Network error during upload', 0)); });
  125. xhr.send(form);
  126. });
  127. }
  128. function delay(ms) {
  129. return new Promise(function (resolve) { setTimeout(resolve, ms); });
  130. }
  131. /* Thumbnail as JPEG blob; null when the browser cannot decode the file
  132. (e.g. RAW) — the original still uploads untouched.
  133. createImageBitmap gets a resize hint so it can downsample while decoding
  134. (a JPEG decoder scales by DCT factors) instead of materialising a
  135. full-resolution bitmap — much faster and far less memory on 40MP files.
  136. Only the width is given, so the aspect ratio is preserved; the canvas
  137. step below still fixes the exact long edge. The hint is skipped for small
  138. files, where it could upscale before we downscale again, and browsers
  139. that ignore resizeWidth simply return the full-size bitmap. */
  140. function makeThumb(file) {
  141. var options = { imageOrientation: 'from-image' };
  142. if (file.size > 2 * 1024 * 1024) {
  143. options.resizeWidth = thumbSize;
  144. options.resizeQuality = 'high';
  145. }
  146. var decode = window.createImageBitmap
  147. ? createImageBitmap(file, options)
  148. : new Promise(function (resolve, reject) {
  149. var img = new Image();
  150. img.onload = function () { resolve(img); };
  151. img.onerror = reject;
  152. img.src = URL.createObjectURL(file);
  153. });
  154. return decode.then(function (src) {
  155. var w = src.width, h = src.height;
  156. var scale = Math.min(1, thumbSize / Math.max(w, h));
  157. var canvas = document.createElement('canvas');
  158. canvas.width = Math.max(1, Math.round(w * scale));
  159. canvas.height = Math.max(1, Math.round(h * scale));
  160. canvas.getContext('2d').drawImage(src, 0, 0, canvas.width, canvas.height);
  161. if (src.close) src.close();
  162. return new Promise(function (resolve) {
  163. canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', thumbQuality);
  164. });
  165. }).catch(function () { return null; });
  166. }
  167. function uploadOne(file, row) {
  168. var bar = row.querySelector('.bar i');
  169. setState(row, 'thumbnail');
  170. return makeThumb(file).then(function (thumbBlob) {
  171. // The same FormData is re-sent on retry: it reads from the File on
  172. // disk each time, so nothing is buffered between attempts.
  173. var form = new FormData();
  174. form.append('slug', slug);
  175. if (uploadKey) form.append('key', uploadKey);
  176. form.append('original', file, file.name);
  177. if (thumbBlob) form.append('thumb', thumbBlob, 'thumb.jpg');
  178. function attempt(n) {
  179. setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);
  180. bar.style.width = '0%';
  181. return sendForm(form, function (f) {
  182. bar.style.width = Math.round(f * 100) + '%';
  183. }).catch(function (err) {
  184. if (!err.transient || n >= maxAttempts) throw err;
  185. // Back off so a briefly overloaded host is not hammered by
  186. // every parallel slot at once.
  187. return delay(1000 * n).then(function () { return attempt(n + 1); });
  188. });
  189. }
  190. return attempt(1);
  191. });
  192. }
  193. })();