Skip to main content

max / makenotwork

11.9 KB · 293 lines History Blame Raw
1 /**
2 * Shared S3 upload utilities.
3 *
4 * Usage:
5 * const uploader = new S3Uploader({
6 * filenameEl: document.getElementById('upload-filename'),
7 * percentEl: document.getElementById('upload-percent'),
8 * progressBar: document.getElementById('progress-bar'),
9 * });
10 * uploader.upload(presignedUrl, file, s3Key, fallbackContentType)
11 * .then(s3Key => { ... })
12 * .catch(err => { ... });
13 * uploader.cancel();
14 */
15
16 function S3Uploader(opts) {
17 this.filenameEl = opts.filenameEl;
18 this.percentEl = opts.percentEl;
19 this.progressBar = opts.progressBar;
20 this.speedEl = opts.speedEl || null;
21 this._xhr = null;
22 }
23
24 /** Start an upload. Returns a Promise resolving to the s3Key. */
25 S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType, cacheControl) {
26 var self = this;
27 var startTime = Date.now();
28
29 if (self.filenameEl) self.filenameEl.textContent = file.name;
30 if (self.percentEl) self.percentEl.textContent = '0%';
31 if (self.progressBar) self.progressBar.style.width = '0%';
32 if (self.speedEl) self.speedEl.textContent = '';
33
34 return new Promise(function(resolve, reject) {
35 var xhr = new XMLHttpRequest();
36 self._xhr = xhr;
37
38 xhr.upload.addEventListener('progress', function(e) {
39 if (e.lengthComputable) {
40 var percent = Math.round((e.loaded / e.total) * 100);
41 if (self.percentEl) self.percentEl.textContent = percent + '%';
42 if (self.progressBar) self.progressBar.style.width = percent + '%';
43 if (self.speedEl && e.loaded > 0) {
44 var elapsed = (Date.now() - startTime) / 1000;
45 if (elapsed > 0.5) {
46 var speed = e.loaded / elapsed;
47 var remaining = (e.total - e.loaded) / speed;
48 var speedStr = speed > 1024 * 1024
49 ? (speed / (1024 * 1024)).toFixed(1) + ' MB/s'
50 : (speed / 1024).toFixed(0) + ' KB/s';
51 // Rounded once, before the split into minutes and
52 // seconds. Rounding both parts read 125 seconds as
53 // "3m 5s", the minutes having already absorbed the
54 // remainder that was then added again. Same fix as
55 // frontend/src/islands/uploader/s3.logic.ts, which is
56 // the typed port of this block.
57 var whole = Math.ceil(remaining);
58 var etaStr = whole < 60
59 ? whole + 's'
60 : Math.floor(whole / 60) + 'm ' + (whole % 60) + 's';
61 self.speedEl.textContent = speedStr + ', ' + etaStr + ' remaining';
62 }
63 }
64 }
65 });
66
67 xhr.addEventListener('load', function() {
68 self._xhr = null;
69 if (xhr.status >= 200 && xhr.status < 300) {
70 resolve(s3Key);
71 } else {
72 reject(new Error('Upload failed: ' + xhr.status));
73 }
74 });
75
76 xhr.addEventListener('error', function() {
77 self._xhr = null;
78 reject(new Error('Network error during upload'));
79 });
80
81 xhr.addEventListener('abort', function() {
82 self._xhr = null;
83 reject(new Error('Upload cancelled'));
84 });
85
86 xhr.open('PUT', url);
87 xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream');
88 if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl);
89 xhr.send(file);
90 });
91 };
92
93 /** Abort any in-progress upload. */
94 S3Uploader.prototype.cancel = function() {
95 if (this._xhr) {
96 this._xhr.abort();
97 this._xhr = null;
98 }
99 };
100
101 /* Dropzone helpers, wire up drag/drop on an element */
102 function initDropzone(dropzoneEl, fileInputEl, onFile) {
103 dropzoneEl.addEventListener('dragover', function(e) {
104 e.preventDefault();
105 dropzoneEl.classList.add('dragover');
106 });
107 dropzoneEl.addEventListener('dragleave', function() {
108 dropzoneEl.classList.remove('dragover');
109 });
110 dropzoneEl.addEventListener('drop', function(e) {
111 e.preventDefault();
112 dropzoneEl.classList.remove('dragover');
113 if (e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
114 });
115 dropzoneEl.addEventListener('click', function(e) {
116 // Not when the picker or its label was clicked. The input is inside the
117 // drop area now that the markup comes from the description, and both of
118 // those already open the dialog, so opening it again on the way up is a
119 // second dialog.
120 if (e.target === fileInputEl) return;
121 if (e.target.closest && e.target.closest('label')) return;
122 fileInputEl.click();
123 });
124 fileInputEl.addEventListener('change', function() {
125 if (fileInputEl.files[0]) onFile(fileInputEl.files[0]);
126 });
127 }
128
129 /**
130 * The described-upload binder.
131 *
132 * Shape 3 of the conversion plan. A described upload emits `data-sends` (where
133 * the bytes go), `data-vals` (what rides with them) and `data-awaiting` (that
134 * the call waits) and performs nothing, because the chain behind that one
135 * address is three requests and no renderer can be told about them
136 * (quasicoherent `a81384d4`). This is the host half: it finds every such field,
137 * binds the drop gesture the description deliberately does not carry, and runs
138 * presign, PUT and confirm.
139 *
140 * The chain is derived from the one address rather than configured twice: every
141 * upload family here answers at `<something>/presign` and `<something>/confirm`
142 * (`/api/upload`, `/api/versions/{id}/upload`, `/api/internal/upload`), so the
143 * confirm route is the presign route with its last segment swapped.
144 *
145 * Two host attributes, both about what happens after the bytes land and neither
146 * of them describable: `data-upload-goes` is an address to follow, and
147 * `data-upload-refreshes` is a control to press. They sit on an ancestor of the
148 * field, since they belong to the surface rather than to the file.
149 */
150 (function() {
151 function payload(sender) {
152 try {
153 return JSON.parse(sender.dataset.vals || '{}');
154 } catch (e) {
155 return {};
156 }
157 }
158
159 function confirmUrl(presign) {
160 return presign.replace(/\/presign$/, '/confirm');
161 }
162
163 function jsonPost(url, body) {
164 return fetch(url, {
165 method: 'POST',
166 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
167 body: JSON.stringify(body)
168 }).then(function(res) {
169 if (!res.ok) {
170 return res.json().catch(function() { return {}; }).then(function(d) {
171 throw new Error(d.error || 'Upload failed');
172 });
173 }
174 return res.json().catch(function() { return {}; });
175 });
176 }
177
178 /* The bar, built rather than templated: progress is the renderer's to
179 observe and the host's to draw, so no markup for it is written down. */
180 function progressPanel() {
181 var panel = document.createElement('div');
182 panel.className = 'upload-progress';
183 panel.innerHTML =
184 '<div class="progress-info"><span data-upload-name></span>' +
185 '<span data-upload-percent>0%</span></div>' +
186 '<div class="progress-bar-container"><div class="progress-bar"></div></div>' +
187 '<div class="upload-speed-text" data-upload-speed></div>' +
188 '<button type="button" class="btn-secondary" data-upload-cancel>Cancel</button>';
189 return panel;
190 }
191
192 function bind(sender) {
193 if (sender.dataset.uploadBound) return;
194 var input = sender.querySelector('input[type="file"]');
195 if (!input) return;
196 sender.dataset.uploadBound = '1';
197
198 var area = sender.closest('.file-upload-area') || sender;
199 var surface = sender.closest('[data-upload-goes], [data-upload-refreshes]');
200 var panel = null;
201 var uploader = null;
202
203 initDropzone(area, input, send);
204
205 function done() {
206 if (panel) { panel.remove(); panel = null; }
207 input.value = '';
208 }
209
210 function send(file) {
211 panel = progressPanel();
212 area.insertAdjacentElement('afterend', panel);
213 uploader = new S3Uploader({
214 filenameEl: panel.querySelector('[data-upload-name]'),
215 percentEl: panel.querySelector('[data-upload-percent]'),
216 progressBar: panel.querySelector('.progress-bar'),
217 speedEl: panel.querySelector('[data-upload-speed]'),
218 });
219 panel.querySelector('[data-upload-cancel]').addEventListener('click', function() {
220 uploader.cancel();
221 done();
222 });
223
224 var presign = sender.dataset.sends;
225 var carried = payload(sender);
226 // What to call the bytes when the browser will not say. The signed
227 // URL binds the content type, so the PUT has to send back exactly
228 // what the presign asked for, and `audio/*` will not accept the
229 // generic answer.
230 var fallback = area.dataset.uploadFallback || 'application/octet-stream';
231 jsonPost(presign, Object.assign({}, carried, {
232 file_name: file.name,
233 content_type: file.type || fallback,
234 file_size_bytes: file.size
235 }))
236 .then(function(data) {
237 if (data.max_file_bytes && file.size > data.max_file_bytes) {
238 var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
239 var fileMB = (file.size / (1024 * 1024)).toFixed(1);
240 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.');
241 }
242 return uploader.upload(data.upload_url, file, data.s3_key, fallback, data.cache_control);
243 })
244 .then(function(s3Key) {
245 return jsonPost(confirmUrl(presign), Object.assign({}, carried, {
246 s3_key: s3Key,
247 file_size_bytes: file.size
248 }));
249 })
250 .then(function(result) {
251 done();
252 /* The scanner held the file, so the creator is told why their
253 content is not public yet. */
254 if (result && result.pending_review) {
255 showToast(
256 'Upload accepted but held for review: our scanner flagged it. ' +
257 "You'll get an email once it's cleared.",
258 'warning'
259 );
260 }
261 if (!surface) return;
262 if (surface.dataset.uploadGoes) {
263 window.location.href = surface.dataset.uploadGoes;
264 } else if (surface.dataset.uploadRefreshes) {
265 var control = document.querySelector(surface.dataset.uploadRefreshes);
266 if (control) control.click();
267 }
268 })
269 .catch(function(err) {
270 done();
271 showToast(err.message || 'Upload failed');
272 });
273 }
274 }
275
276 function bindAll(root) {
277 (root || document).querySelectorAll('[data-sends]').forEach(bind);
278 }
279
280 if (document.readyState === 'loading') {
281 document.addEventListener('DOMContentLoaded', function() { bindAll(); });
282 } else {
283 bindAll();
284 }
285
286 /* Every described upload swapped in later, on the event htmx settles with. */
287 document.body ? document.body.addEventListener('htmx:after:settle', function(e) {
288 bindAll(e.target);
289 }) : document.addEventListener('DOMContentLoaded', function() {
290 document.body.addEventListener('htmx:after:settle', function(e) { bindAll(e.target); });
291 });
292 })();
293