/** * Shared S3 upload utilities. * * Usage: * const uploader = new S3Uploader({ * filenameEl: document.getElementById('upload-filename'), * percentEl: document.getElementById('upload-percent'), * progressBar: document.getElementById('progress-bar'), * }); * uploader.upload(presignedUrl, file, s3Key, fallbackContentType) * .then(s3Key => { ... }) * .catch(err => { ... }); * uploader.cancel(); */ function S3Uploader(opts) { this.filenameEl = opts.filenameEl; this.percentEl = opts.percentEl; this.progressBar = opts.progressBar; this.speedEl = opts.speedEl || null; this._xhr = null; } /** Start an upload. Returns a Promise resolving to the s3Key. */ S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType, cacheControl) { var self = this; var startTime = Date.now(); if (self.filenameEl) self.filenameEl.textContent = file.name; if (self.percentEl) self.percentEl.textContent = '0%'; if (self.progressBar) self.progressBar.style.width = '0%'; if (self.speedEl) self.speedEl.textContent = ''; return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); self._xhr = xhr; xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { var percent = Math.round((e.loaded / e.total) * 100); if (self.percentEl) self.percentEl.textContent = percent + '%'; if (self.progressBar) self.progressBar.style.width = percent + '%'; if (self.speedEl && e.loaded > 0) { var elapsed = (Date.now() - startTime) / 1000; if (elapsed > 0.5) { var speed = e.loaded / elapsed; var remaining = (e.total - e.loaded) / speed; var speedStr = speed > 1024 * 1024 ? (speed / (1024 * 1024)).toFixed(1) + ' MB/s' : (speed / 1024).toFixed(0) + ' KB/s'; // Rounded once, before the split into minutes and // seconds. Rounding both parts read 125 seconds as // "3m 5s", the minutes having already absorbed the // remainder that was then added again. Same fix as // frontend/src/islands/uploader/s3.logic.ts, which is // the typed port of this block. var whole = Math.ceil(remaining); var etaStr = whole < 60 ? whole + 's' : Math.floor(whole / 60) + 'm ' + (whole % 60) + 's'; self.speedEl.textContent = speedStr + ', ' + etaStr + ' remaining'; } } } }); xhr.addEventListener('load', function() { self._xhr = null; if (xhr.status >= 200 && xhr.status < 300) { resolve(s3Key); } else { reject(new Error('Upload failed: ' + xhr.status)); } }); xhr.addEventListener('error', function() { self._xhr = null; reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', function() { self._xhr = null; reject(new Error('Upload cancelled')); }); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream'); if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl); xhr.send(file); }); }; /** Abort any in-progress upload. */ S3Uploader.prototype.cancel = function() { if (this._xhr) { this._xhr.abort(); this._xhr = null; } }; /* Dropzone helpers, wire up drag/drop on an element */ function initDropzone(dropzoneEl, fileInputEl, onFile) { dropzoneEl.addEventListener('dragover', function(e) { e.preventDefault(); dropzoneEl.classList.add('dragover'); }); dropzoneEl.addEventListener('dragleave', function() { dropzoneEl.classList.remove('dragover'); }); dropzoneEl.addEventListener('drop', function(e) { e.preventDefault(); dropzoneEl.classList.remove('dragover'); if (e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]); }); dropzoneEl.addEventListener('click', function(e) { // Not when the picker or its label was clicked. The input is inside the // drop area now that the markup comes from the description, and both of // those already open the dialog, so opening it again on the way up is a // second dialog. if (e.target === fileInputEl) return; if (e.target.closest && e.target.closest('label')) return; fileInputEl.click(); }); fileInputEl.addEventListener('change', function() { if (fileInputEl.files[0]) onFile(fileInputEl.files[0]); }); } /** * The described-upload binder. * * Shape 3 of the conversion plan. A described upload emits `data-sends` (where * the bytes go), `data-vals` (what rides with them) and `data-awaiting` (that * the call waits) and performs nothing, because the chain behind that one * address is three requests and no renderer can be told about them * (quasicoherent `a81384d4`). This is the host half: it finds every such field, * binds the drop gesture the description deliberately does not carry, and runs * presign, PUT and confirm. * * The chain is derived from the one address rather than configured twice: every * upload family here answers at `/presign` and `/confirm` * (`/api/upload`, `/api/versions/{id}/upload`, `/api/internal/upload`), so the * confirm route is the presign route with its last segment swapped. * * Four host attributes, all about what happens after the bytes land and none of * them describable. Three sit on an ancestor of the field, since they belong to * the surface rather than to the file: `data-upload-goes` is an address to * follow, `data-upload-refreshes` is a control to press, and * `data-upload-fills` is a selector for the input that takes the URL the confirm * answered with. The fourth, `data-upload-shows`, marks each place inside that * surface where the picture itself goes; within one of those, `data-upload-empty` * is the placeholder to hide and `data-upload-filled` the element to reveal, and * the `img` found there has its `src` set. That pair replaced * `frontend/src/islands/uploader/image-uploader.ts`, which built the same markup * from JS against hardcoded element ids. */ (function() { function payload(sender) { try { return JSON.parse(sender.dataset.vals || '{}'); } catch (e) { return {}; } } function confirmUrl(presign) { return presign.replace(/\/presign$/, '/confirm'); } function jsonPost(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify(body) }).then(function(res) { if (!res.ok) { return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Upload failed'); }); } return res.json().catch(function() { return {}; }); }); } /* The bar, built rather than templated: progress is the renderer's to observe and the host's to draw, so no markup for it is written down. */ function progressPanel() { var panel = document.createElement('div'); panel.className = 'upload-progress'; panel.innerHTML = '
' + '0%
' + '
' + '
' + ''; return panel; } /* Where a returned URL goes. The confirm answers with the address of the stored image; the surface says which input holds it for the form to post and which regions show it. An image whose element is already in the markup is why nothing is constructed here: the server rendered the placeholder and the picture side by side, so landing one is setting a `src` and swapping which of the two is hidden. */ function land(surface, url) { if (!surface) return; var fills = surface.dataset.uploadFills; if (fills) { var input = document.querySelector(fills); if (input) input.value = url; } surface.querySelectorAll('[data-upload-shows]').forEach(function(shown) { var img = shown.querySelector('img'); if (img) img.src = url; shown.querySelectorAll('[data-upload-empty]').forEach(function(el) { el.hidden = true; }); shown.querySelectorAll('[data-upload-filled]').forEach(function(el) { el.hidden = false; }); }); } function bind(sender) { if (sender.dataset.uploadBound) return; var input = sender.querySelector('input[type="file"]'); if (!input) return; sender.dataset.uploadBound = '1'; var area = sender.closest('.file-upload-area') || sender; var surface = sender.closest('[data-upload-goes], [data-upload-refreshes], [data-upload-fills]'); var panel = null; var uploader = null; initDropzone(area, input, send); function done() { if (panel) { panel.remove(); panel = null; } input.value = ''; } function send(file) { panel = progressPanel(); area.insertAdjacentElement('afterend', panel); uploader = new S3Uploader({ filenameEl: panel.querySelector('[data-upload-name]'), percentEl: panel.querySelector('[data-upload-percent]'), progressBar: panel.querySelector('.progress-bar'), speedEl: panel.querySelector('[data-upload-speed]'), }); panel.querySelector('[data-upload-cancel]').addEventListener('click', function() { uploader.cancel(); done(); }); var presign = sender.dataset.sends; var carried = payload(sender); // What to call the bytes when the browser will not say. The signed // URL binds the content type, so the PUT has to send back exactly // what the presign asked for, and `audio/*` will not accept the // generic answer. var fallback = area.dataset.uploadFallback || 'application/octet-stream'; jsonPost(presign, Object.assign({}, carried, { file_name: file.name, content_type: file.type || fallback, file_size_bytes: file.size })) .then(function(data) { if (data.max_file_bytes && file.size > data.max_file_bytes) { var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0); var fileMB = (file.size / (1024 * 1024)).toFixed(1); throw new Error('File is ' + fileMB + ' MB but your plan allows up to ' + limitMB + ' MB per file. Upgrade your tier or use a smaller file.'); } return uploader.upload(data.upload_url, file, data.s3_key, fallback, data.cache_control); }) .then(function(s3Key) { return jsonPost(confirmUrl(presign), Object.assign({}, carried, { s3_key: s3Key, file_size_bytes: file.size })); }) .then(function(result) { done(); /* The scanner held the file, so the creator is told why their content is not public yet. */ if (result && result.pending_review) { showToast( 'Upload accepted but held for review: our scanner flagged it. ' + "You'll get an email once it's cleared.", 'warning' ); } if (!surface) return; /* `image_url` is what both image confirms answer with, and the only field either of them has that anything reads. */ if (result && result.image_url) land(surface, result.image_url); if (surface.dataset.uploadGoes) { window.location.href = surface.dataset.uploadGoes; } else if (surface.dataset.uploadRefreshes) { var control = document.querySelector(surface.dataset.uploadRefreshes); if (control) control.click(); } }) .catch(function(err) { done(); showToast(err.message || 'Upload failed'); }); } } function bindAll(root) { (root || document).querySelectorAll('[data-sends]').forEach(bind); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { bindAll(); }); } else { bindAll(); } /* Every described upload swapped in later, on the event htmx settles with. */ document.body ? document.body.addEventListener('htmx:after:settle', function(e) { bindAll(e.target); }) : document.addEventListener('DOMContentLoaded', function() { document.body.addEventListener('htmx:after:settle', function(e) { bindAll(e.target); }); }); })();