admin.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. /*
  2. * Gallery bulk uploader.
  3. *
  4. * Per file, one request:
  5. * 1. draw a small JPEG thumbnail on a canvas (browser-side), and when the
  6. * gallery caps its resolution, a downscaled copy of the original too
  7. * 2. POST the original + thumbnail to admin/api.php as multipart/form-data
  8. * 3. the webhost streams both to S3 and registers the image
  9. *
  10. * One file per request keeps each PHP process small, so the size of the whole
  11. * gallery upload never matters — only the largest single image. The browser
  12. * never sees an S3 URL or credential for writing.
  13. *
  14. * Several files are in flight at once (data-concurrency, default 3). Each file
  15. * is store-and-forward — the webhost receives the whole body before it starts
  16. * the S3 PUT — so a single-file queue leaves the uplink idle for the entire
  17. * webhost→S3 leg and for every thumbnail decode. Overlapping requests keeps it
  18. * saturated; the server appends to the gallery JSON under a lock, so parallel
  19. * completions cannot lose entries.
  20. */
  21. (function () {
  22. 'use strict';
  23. var zone = document.getElementById('dropzone');
  24. if (!zone) return;
  25. var input = document.getElementById('file-input');
  26. var list = document.getElementById('upload-list');
  27. var countEl = document.getElementById('img-count');
  28. var api = zone.dataset.api;
  29. var slug = zone.dataset.slug;
  30. var csrf = zone.dataset.csrf;
  31. // Guest upload links pass a per-gallery key; the admin edit page sets none.
  32. var uploadKey = zone.dataset.key || '';
  33. var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
  34. var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
  35. // Per-gallery cap on the longest edge of the stored image; 0 = keep the
  36. // original. Best-effort by nature: it only applies to files this browser
  37. // can decode, so a RAW still goes up untouched.
  38. var maxRes = parseInt(zone.dataset.maxResolution, 10) || 0;
  39. var resizeQuality = parseFloat(zone.dataset.resizeQuality) || 0.9;
  40. // How many uploads may be in flight at once. Small on purpose: each one
  41. // occupies a PHP worker on the webhost for its whole S3 round trip.
  42. var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
  43. // Transient failures (network drop, 5xx, throttling) are retried
  44. // automatically before the row is marked failed.
  45. var maxAttempts = 3;
  46. var queue = [];
  47. var active = 0;
  48. zone.addEventListener('click', function () { input.click(); });
  49. input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
  50. ['dragenter', 'dragover'].forEach(function (ev) {
  51. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
  52. });
  53. ['dragleave', 'drop'].forEach(function (ev) {
  54. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
  55. });
  56. zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
  57. window.addEventListener('beforeunload', function (e) {
  58. if (active || queue.length) { e.preventDefault(); e.returnValue = ''; }
  59. });
  60. function enqueue(files) {
  61. Array.prototype.forEach.call(files, function (file) {
  62. var row = document.createElement('div');
  63. row.className = 'upload-item';
  64. row.innerHTML = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
  65. row.querySelector('.name').textContent = file.name;
  66. list.appendChild(row);
  67. queue.push({ file: file, row: row });
  68. });
  69. pump();
  70. }
  71. /* Start jobs until the parallel slots are full; called again as each ends. */
  72. function pump() {
  73. while (active < maxParallel && queue.length) {
  74. run(queue.shift());
  75. }
  76. }
  77. function run(job) {
  78. active++;
  79. uploadOne(job.file, job.row)
  80. .then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
  81. .catch(function (err) {
  82. setState(job.row, 'failed', 'error');
  83. job.row.title = String(err);
  84. var retry = document.createElement('a');
  85. retry.href = '#';
  86. retry.textContent = ' retry';
  87. retry.addEventListener('click', function (e) {
  88. e.preventDefault();
  89. retry.remove();
  90. setState(job.row, 'queued', '');
  91. queue.push(job);
  92. pump();
  93. });
  94. job.row.appendChild(retry);
  95. })
  96. .finally(function () { active--; pump(); });
  97. }
  98. function setState(row, text, cls) {
  99. var el = row.querySelector('.state');
  100. el.textContent = text;
  101. el.className = 'state ' + (cls || '');
  102. }
  103. function bumpCount() {
  104. if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
  105. }
  106. /* An error worth repeating: the request never landed, or the server said it
  107. was a temporary condition. A 4xx is a real rejection (bad CSRF token,
  108. wrong file type, gone gallery) and must not be retried. */
  109. function failure(message, status) {
  110. var err = new Error(message);
  111. err.transient = status === 0 || status === 408 || status === 429 || status >= 500;
  112. return err;
  113. }
  114. /* POST multipart to the webhost with upload progress (fetch has none → XHR). */
  115. function sendForm(form, onProgress) {
  116. return new Promise(function (resolve, reject) {
  117. var xhr = new XMLHttpRequest();
  118. xhr.open('POST', api);
  119. xhr.setRequestHeader('X-CSRF-Token', csrf);
  120. // Let the browser set Content-Type (with the multipart boundary).
  121. xhr.upload.addEventListener('progress', function (e) {
  122. if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
  123. });
  124. xhr.addEventListener('load', function () {
  125. var data = {};
  126. try { data = JSON.parse(xhr.responseText); } catch (err) {}
  127. if (xhr.status >= 200 && xhr.status < 300 && data.ok) resolve(data);
  128. else reject(failure((data && data.error) || ('Upload failed (' + xhr.status + ')'), xhr.status));
  129. });
  130. xhr.addEventListener('error', function () { reject(failure('Network error during upload', 0)); });
  131. xhr.send(form);
  132. });
  133. }
  134. function delay(ms) {
  135. return new Promise(function (resolve) { setTimeout(resolve, ms); });
  136. }
  137. /* Decode a file to a bitmap, downsampled to hintEdge where the browser can
  138. do it during the decode itself (a JPEG decoder scales by DCT factors)
  139. instead of materialising a full-resolution bitmap — much faster and far
  140. less memory on 40MP files. Only the width is given, so the aspect ratio
  141. is preserved; the canvas step below still fixes the exact long edge, and
  142. browsers that ignore resizeWidth simply return the full-size bitmap.
  143. Pass hintEdge 0 to decode at natural size. resizeWidth scales *up* as
  144. readily as down and the bitmap keeps no record of which happened, so a
  145. hinted decode cannot tell "shrunk from 6000px" from "stretched from
  146. 900px". Harmless for a thumbnail, which ends up small either way; not
  147. harmless for pixels we are about to store, which is why the resize path
  148. decodes unhinted. Small files skip the hint for the same reason. */
  149. function decodeImage(file, hintEdge) {
  150. var options = { imageOrientation: 'from-image' };
  151. if (hintEdge && file.size > 2 * 1024 * 1024) {
  152. options.resizeWidth = hintEdge;
  153. options.resizeQuality = 'high';
  154. }
  155. if (window.createImageBitmap) return createImageBitmap(file, options);
  156. return new Promise(function (resolve, reject) {
  157. var img = new Image();
  158. img.onload = function () { resolve(img); };
  159. img.onerror = reject;
  160. img.src = URL.createObjectURL(file);
  161. });
  162. }
  163. /* One JPEG blob, longest edge at most maxEdge. Never upscales. */
  164. function drawScaled(src, maxEdge, quality) {
  165. var scale = Math.min(1, maxEdge / Math.max(src.width, src.height));
  166. var canvas = document.createElement('canvas');
  167. canvas.width = Math.max(1, Math.round(src.width * scale));
  168. canvas.height = Math.max(1, Math.round(src.height * scale));
  169. var ctx = canvas.getContext('2d');
  170. // JPEG has no alpha, so a transparent PNG would encode onto black.
  171. ctx.fillStyle = '#fff';
  172. ctx.fillRect(0, 0, canvas.width, canvas.height);
  173. ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
  174. return new Promise(function (resolve) {
  175. canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', quality);
  176. });
  177. }
  178. /* Decode/resize runs one file at a time even though uploads overlap: it is
  179. main-thread canvas work, and a capped gallery decodes at full size, so
  180. three at once is three 40MP bitmaps — enough to jank or exhaust the
  181. phones that use the guest link. Parallelism is worth having on the
  182. network leg, not here. */
  183. var cpuChain = Promise.resolve();
  184. function serialize(work) {
  185. var result = cpuChain.then(work);
  186. // Keep the chain alive after a rejection, and unhandled-rejection-free.
  187. cpuChain = result.catch(function () {});
  188. return result;
  189. }
  190. /* Derive what we send: the grid thumbnail, plus a downscaled original when
  191. the gallery caps its resolution and the image exceeds it — both off a
  192. single decode. Returns nulls when the browser cannot decode the file
  193. (e.g. RAW) — the original then uploads untouched, thumbnail-less,
  194. exactly as before.
  195. An uncapped gallery decodes exactly as it did before this option
  196. existed: hinted down to the thumbnail, since nothing else is kept. A
  197. capped one pays for a full-size decode, which is why this stage is
  198. serialised — one large bitmap at a time, not one per upload slot. */
  199. function prepare(file) {
  200. return serialize(function () {
  201. return decodeImage(file, maxRes > 0 ? 0 : thumbSize).then(function (src) {
  202. var oversized = maxRes > 0 && Math.max(src.width, src.height) > maxRes;
  203. return drawScaled(src, thumbSize, thumbQuality).then(function (thumb) {
  204. if (!oversized) return { thumb: thumb, resized: null };
  205. return drawScaled(src, maxRes, resizeQuality).then(function (resized) {
  206. return { thumb: thumb, resized: resized };
  207. });
  208. }).finally(function () { if (src.close) src.close(); });
  209. });
  210. }).catch(function () { return { thumb: null, resized: null }; });
  211. }
  212. /* A re-encoded file must not be stored under its old extension. */
  213. function jpegName(name) {
  214. return name.replace(/\.[^.\/]*$/, '') + '.jpg';
  215. }
  216. function uploadOne(file, row) {
  217. var bar = row.querySelector('.bar i');
  218. setState(row, maxRes ? 'resizing' : 'thumbnail');
  219. return prepare(file).then(function (out) {
  220. // The same FormData is re-sent on retry. An untouched original is
  221. // read from the File on disk each time; a resized blob is held in
  222. // memory for the job (a megabyte or two), which also makes a retry
  223. // cheaper — nothing is decoded or re-encoded twice.
  224. var form = new FormData();
  225. form.append('slug', slug);
  226. if (uploadKey) form.append('key', uploadKey);
  227. if (out.resized) form.append('original', out.resized, jpegName(file.name));
  228. else form.append('original', file, file.name);
  229. if (out.thumb) form.append('thumb', out.thumb, 'thumb.jpg');
  230. function attempt(n) {
  231. setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);
  232. bar.style.width = '0%';
  233. return sendForm(form, function (f) {
  234. bar.style.width = Math.round(f * 100) + '%';
  235. }).catch(function (err) {
  236. if (!err.transient || n >= maxAttempts) throw err;
  237. // Back off so a briefly overloaded host is not hammered by
  238. // every parallel slot at once.
  239. return delay(1000 * n).then(function () { return attempt(n + 1); });
  240. });
  241. }
  242. return attempt(1);
  243. });
  244. }
  245. })();