/* * Gallery bulk uploader. * * Per file, one request: * 1. draw a small JPEG thumbnail on a canvas (browser-side), and when the * gallery caps its resolution, a downscaled copy of the original too * 2. POST the original + thumbnail to admin/api.php as multipart/form-data * 3. the webhost streams both to S3 and registers the image * * One file per request keeps each PHP process small, so the size of the whole * gallery upload never matters — only the largest single image. The browser * never sees an S3 URL or credential for writing. * * Several files are in flight at once (data-concurrency, default 3). Each file * is store-and-forward — the webhost receives the whole body before it starts * the S3 PUT — so a single-file queue leaves the uplink idle for the entire * webhost→S3 leg and for every thumbnail decode. Overlapping requests keeps it * saturated; the server appends to the gallery JSON under a lock, so parallel * completions cannot lose entries. */ (function () { 'use strict'; var zone = document.getElementById('dropzone'); if (!zone) return; var input = document.getElementById('file-input'); var list = document.getElementById('upload-list'); var countEl = document.getElementById('img-count'); var api = zone.dataset.api; var slug = zone.dataset.slug; var csrf = zone.dataset.csrf; // Guest upload links pass a per-gallery key; the admin edit page sets none. var uploadKey = zone.dataset.key || ''; var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600; var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8; // Per-gallery cap on the longest edge of the stored image; 0 = keep the // original. Best-effort by nature: it only applies to files this browser // can decode, so a RAW still goes up untouched. var maxRes = parseInt(zone.dataset.maxResolution, 10) || 0; var resizeQuality = parseFloat(zone.dataset.resizeQuality) || 0.9; // How many uploads may be in flight at once. Small on purpose: each one // occupies a PHP worker on the webhost for its whole S3 round trip. var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3); // Transient failures (network drop, 5xx, throttling) are retried // automatically before the row is marked failed. var maxAttempts = 3; var queue = []; var active = 0; zone.addEventListener('click', function () { input.click(); }); input.addEventListener('change', function () { enqueue(input.files); input.value = ''; }); ['dragenter', 'dragover'].forEach(function (ev) { zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); }); }); ['dragleave', 'drop'].forEach(function (ev) { zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); }); }); zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); }); window.addEventListener('beforeunload', function (e) { if (active || queue.length) { e.preventDefault(); e.returnValue = ''; } }); function enqueue(files) { Array.prototype.forEach.call(files, function (file) { var row = document.createElement('div'); row.className = 'upload-item'; row.innerHTML = 'queued'; row.querySelector('.name').textContent = file.name; list.appendChild(row); queue.push({ file: file, row: row }); }); pump(); } /* Start jobs until the parallel slots are full; called again as each ends. */ function pump() { while (active < maxParallel && queue.length) { run(queue.shift()); } } function run(job) { active++; uploadOne(job.file, job.row) .then(function () { setState(job.row, 'done', 'done'); bumpCount(); }) .catch(function (err) { setState(job.row, 'failed', 'error'); job.row.title = String(err); var retry = document.createElement('a'); retry.href = '#'; retry.textContent = ' retry'; retry.addEventListener('click', function (e) { e.preventDefault(); retry.remove(); setState(job.row, 'queued', ''); queue.push(job); pump(); }); job.row.appendChild(retry); }) .finally(function () { active--; pump(); }); } function setState(row, text, cls) { var el = row.querySelector('.state'); el.textContent = text; el.className = 'state ' + (cls || ''); } function bumpCount() { if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1); } /* An error worth repeating: the request never landed, or the server said it was a temporary condition. A 4xx is a real rejection (bad CSRF token, wrong file type, gone gallery) and must not be retried. */ function failure(message, status) { var err = new Error(message); err.transient = status === 0 || status === 408 || status === 429 || status >= 500; return err; } /* POST multipart to the webhost with upload progress (fetch has none → XHR). */ function sendForm(form, onProgress) { return new Promise(function (resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', api); xhr.setRequestHeader('X-CSRF-Token', csrf); // Let the browser set Content-Type (with the multipart boundary). xhr.upload.addEventListener('progress', function (e) { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); }); xhr.addEventListener('load', function () { var data = {}; try { data = JSON.parse(xhr.responseText); } catch (err) {} if (xhr.status >= 200 && xhr.status < 300 && data.ok) resolve(data); else reject(failure((data && data.error) || ('Upload failed (' + xhr.status + ')'), xhr.status)); }); xhr.addEventListener('error', function () { reject(failure('Network error during upload', 0)); }); xhr.send(form); }); } function delay(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } /* Decode a file to a bitmap, downsampled to hintEdge where the browser can do it during the decode itself (a JPEG decoder scales by DCT factors) instead of materialising a full-resolution bitmap — much faster and far less memory on 40MP files. Only the width is given, so the aspect ratio is preserved; the canvas step below still fixes the exact long edge, and browsers that ignore resizeWidth simply return the full-size bitmap. Pass hintEdge 0 to decode at natural size. resizeWidth scales *up* as readily as down and the bitmap keeps no record of which happened, so a hinted decode cannot tell "shrunk from 6000px" from "stretched from 900px". Harmless for a thumbnail, which ends up small either way; not harmless for pixels we are about to store, which is why the resize path decodes unhinted. Small files skip the hint for the same reason. */ function decodeImage(file, hintEdge) { var options = { imageOrientation: 'from-image' }; if (hintEdge && file.size > 2 * 1024 * 1024) { options.resizeWidth = hintEdge; options.resizeQuality = 'high'; } if (window.createImageBitmap) return createImageBitmap(file, options); return new Promise(function (resolve, reject) { var img = new Image(); img.onload = function () { resolve(img); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); } /* One JPEG blob, longest edge at most maxEdge. Never upscales. */ function drawScaled(src, maxEdge, quality) { var scale = Math.min(1, maxEdge / Math.max(src.width, src.height)); var canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(src.width * scale)); canvas.height = Math.max(1, Math.round(src.height * scale)); var ctx = canvas.getContext('2d'); // JPEG has no alpha, so a transparent PNG would encode onto black. ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(src, 0, 0, canvas.width, canvas.height); return new Promise(function (resolve) { canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', quality); }); } /* Decode/resize runs one file at a time even though uploads overlap: it is main-thread canvas work, and a capped gallery decodes at full size, so three at once is three 40MP bitmaps — enough to jank or exhaust the phones that use the guest link. Parallelism is worth having on the network leg, not here. */ var cpuChain = Promise.resolve(); function serialize(work) { var result = cpuChain.then(work); // Keep the chain alive after a rejection, and unhandled-rejection-free. cpuChain = result.catch(function () {}); return result; } /* Derive what we send: the grid thumbnail, plus a downscaled original when the gallery caps its resolution and the image exceeds it — both off a single decode. Returns nulls when the browser cannot decode the file (e.g. RAW) — the original then uploads untouched, thumbnail-less, exactly as before. An uncapped gallery decodes exactly as it did before this option existed: hinted down to the thumbnail, since nothing else is kept. A capped one pays for a full-size decode, which is why this stage is serialised — one large bitmap at a time, not one per upload slot. */ function prepare(file) { return serialize(function () { return decodeImage(file, maxRes > 0 ? 0 : thumbSize).then(function (src) { var oversized = maxRes > 0 && Math.max(src.width, src.height) > maxRes; return drawScaled(src, thumbSize, thumbQuality).then(function (thumb) { if (!oversized) return { thumb: thumb, resized: null }; return drawScaled(src, maxRes, resizeQuality).then(function (resized) { return { thumb: thumb, resized: resized }; }); }).finally(function () { if (src.close) src.close(); }); }); }).catch(function () { return { thumb: null, resized: null }; }); } /* A re-encoded file must not be stored under its old extension. */ function jpegName(name) { return name.replace(/\.[^.\/]*$/, '') + '.jpg'; } function uploadOne(file, row) { var bar = row.querySelector('.bar i'); setState(row, maxRes ? 'resizing' : 'thumbnail'); return prepare(file).then(function (out) { // The same FormData is re-sent on retry. An untouched original is // read from the File on disk each time; a resized blob is held in // memory for the job (a megabyte or two), which also makes a retry // cheaper — nothing is decoded or re-encoded twice. var form = new FormData(); form.append('slug', slug); if (uploadKey) form.append('key', uploadKey); if (out.resized) form.append('original', out.resized, jpegName(file.name)); else form.append('original', file, file.name); if (out.thumb) form.append('thumb', out.thumb, 'thumb.jpg'); function attempt(n) { setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts); bar.style.width = '0%'; return sendForm(form, function (f) { bar.style.width = Math.round(f * 100) + '%'; }).catch(function (err) { if (!err.transient || n >= maxAttempts) throw err; // Back off so a briefly overloaded host is not hammered by // every parallel slot at once. return delay(1000 * n).then(function () { return attempt(n + 1); }); }); } return attempt(1); }); } })();