Skip to main content

max / makenotwork

13.7 KB · 324 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 * Four host attributes, all about what happens after the bytes land and none of
146 * them describable. Three sit on an ancestor of the field, since they belong to
147 * the surface rather than to the file: `data-upload-goes` is an address to
148 * follow, `data-upload-refreshes` is a control to press, and
149 * `data-upload-fills` is a selector for the input that takes the URL the confirm
150 * answered with. The fourth, `data-upload-shows`, marks each place inside that
151 * surface where the picture itself goes; within one of those, `data-upload-empty`
152 * is the placeholder to hide and `data-upload-filled` the element to reveal, and
153 * the `img` found there has its `src` set. That pair replaced
154 * `frontend/src/islands/uploader/image-uploader.ts`, which built the same markup
155 * from JS against hardcoded element ids.
156 */
157 (function() {
158 function payload(sender) {
159 try {
160 return JSON.parse(sender.dataset.vals || '{}');
161 } catch (e) {
162 return {};
163 }
164 }
165
166 function confirmUrl(presign) {
167 return presign.replace(/\/presign$/, '/confirm');
168 }
169
170 function jsonPost(url, body) {
171 return fetch(url, {
172 method: 'POST',
173 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
174 body: JSON.stringify(body)
175 }).then(function(res) {
176 if (!res.ok) {
177 return res.json().catch(function() { return {}; }).then(function(d) {
178 throw new Error(d.error || 'Upload failed');
179 });
180 }
181 return res.json().catch(function() { return {}; });
182 });
183 }
184
185 /* The bar, built rather than templated: progress is the renderer's to
186 observe and the host's to draw, so no markup for it is written down. */
187 function progressPanel() {
188 var panel = document.createElement('div');
189 panel.className = 'upload-progress';
190 panel.innerHTML =
191 '<div class="progress-info"><span data-upload-name></span>' +
192 '<span data-upload-percent>0%</span></div>' +
193 '<div class="progress-bar-container"><div class="progress-bar"></div></div>' +
194 '<div class="upload-speed-text" data-upload-speed></div>' +
195 '<button type="button" class="btn-secondary" data-upload-cancel>Cancel</button>';
196 return panel;
197 }
198
199 /* Where a returned URL goes. The confirm answers with the address of the
200 stored image; the surface says which input holds it for the form to post
201 and which regions show it. An image whose element is already in the
202 markup is why nothing is constructed here: the server rendered the
203 placeholder and the picture side by side, so landing one is setting a
204 `src` and swapping which of the two is hidden. */
205 function land(surface, url) {
206 if (!surface) return;
207 var fills = surface.dataset.uploadFills;
208 if (fills) {
209 var input = document.querySelector(fills);
210 if (input) input.value = url;
211 }
212 surface.querySelectorAll('[data-upload-shows]').forEach(function(shown) {
213 var img = shown.querySelector('img');
214 if (img) img.src = url;
215 shown.querySelectorAll('[data-upload-empty]').forEach(function(el) { el.hidden = true; });
216 shown.querySelectorAll('[data-upload-filled]').forEach(function(el) { el.hidden = false; });
217 });
218 }
219
220 function bind(sender) {
221 if (sender.dataset.uploadBound) return;
222 var input = sender.querySelector('input[type="file"]');
223 if (!input) return;
224 sender.dataset.uploadBound = '1';
225
226 var area = sender.closest('.file-upload-area') || sender;
227 var surface = sender.closest('[data-upload-goes], [data-upload-refreshes], [data-upload-fills]');
228 var panel = null;
229 var uploader = null;
230
231 initDropzone(area, input, send);
232
233 function done() {
234 if (panel) { panel.remove(); panel = null; }
235 input.value = '';
236 }
237
238 function send(file) {
239 panel = progressPanel();
240 area.insertAdjacentElement('afterend', panel);
241 uploader = new S3Uploader({
242 filenameEl: panel.querySelector('[data-upload-name]'),
243 percentEl: panel.querySelector('[data-upload-percent]'),
244 progressBar: panel.querySelector('.progress-bar'),
245 speedEl: panel.querySelector('[data-upload-speed]'),
246 });
247 panel.querySelector('[data-upload-cancel]').addEventListener('click', function() {
248 uploader.cancel();
249 done();
250 });
251
252 var presign = sender.dataset.sends;
253 var carried = payload(sender);
254 // What to call the bytes when the browser will not say. The signed
255 // URL binds the content type, so the PUT has to send back exactly
256 // what the presign asked for, and `audio/*` will not accept the
257 // generic answer.
258 var fallback = area.dataset.uploadFallback || 'application/octet-stream';
259 jsonPost(presign, Object.assign({}, carried, {
260 file_name: file.name,
261 content_type: file.type || fallback,
262 file_size_bytes: file.size
263 }))
264 .then(function(data) {
265 if (data.max_file_bytes && file.size > data.max_file_bytes) {
266 var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
267 var fileMB = (file.size / (1024 * 1024)).toFixed(1);
268 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.');
269 }
270 return uploader.upload(data.upload_url, file, data.s3_key, fallback, data.cache_control);
271 })
272 .then(function(s3Key) {
273 return jsonPost(confirmUrl(presign), Object.assign({}, carried, {
274 s3_key: s3Key,
275 file_size_bytes: file.size
276 }));
277 })
278 .then(function(result) {
279 done();
280 /* The scanner held the file, so the creator is told why their
281 content is not public yet. */
282 if (result && result.pending_review) {
283 showToast(
284 'Upload accepted but held for review: our scanner flagged it. ' +
285 "You'll get an email once it's cleared.",
286 'warning'
287 );
288 }
289 if (!surface) return;
290 /* `image_url` is what both image confirms answer with, and the
291 only field either of them has that anything reads. */
292 if (result && result.image_url) land(surface, result.image_url);
293 if (surface.dataset.uploadGoes) {
294 window.location.href = surface.dataset.uploadGoes;
295 } else if (surface.dataset.uploadRefreshes) {
296 var control = document.querySelector(surface.dataset.uploadRefreshes);
297 if (control) control.click();
298 }
299 })
300 .catch(function(err) {
301 done();
302 showToast(err.message || 'Upload failed');
303 });
304 }
305 }
306
307 function bindAll(root) {
308 (root || document).querySelectorAll('[data-sends]').forEach(bind);
309 }
310
311 if (document.readyState === 'loading') {
312 document.addEventListener('DOMContentLoaded', function() { bindAll(); });
313 } else {
314 bindAll();
315 }
316
317 /* Every described upload swapped in later, on the event htmx settles with. */
318 document.body ? document.body.addEventListener('htmx:after:settle', function(e) {
319 bindAll(e.target);
320 }) : document.addEventListener('DOMContentLoaded', function() {
321 document.body.addEventListener('htmx:after:settle', function(e) { bindAll(e.target); });
322 });
323 })();
324