/** * 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. * * Two host attributes, both about what happens after the bytes land and neither * of them describable: `data-upload-goes` is an address to follow, and * `data-upload-refreshes` is a control to press. They sit on an ancestor of the * field, since they belong to the surface rather than to the file. */ (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; } 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]'); 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; 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); }); }); })();