admin.js 13 KB

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