1// Issue #530: direct-to-R2 uploads for the planner submit wizard. 2// 3// Cloudflare's edge rejects request bodies over 100 MiB on Content-Length before they reach 4// origin, so a large bundle cannot arrive on the submit POST at all. This intercepts the 5// form submission, puts the file bytes somewhere Cloudflare is not in the way, and then lets 6// the original form post the metadata plus a list of staging keys. 7// 8// Exposed as a global (window.gsUpload) loaded once from App.razor, mirroring 9// assessment-map.js and screening-map.js. NOT an inline script and NOT a module: an inline 10// script does not re-execute when Blazor reaches the page by enhanced navigation, which would 11// leave the uploader silently unbound. 12// 13// Graceful degradation is structural. The only thing this does is listen for 'submit'; if the 14// file never loads, or attach() is never called, the form posts natively exactly as it did 15// before, and everything under the Cloudflare ceiling still works. The server can also tell 16// us to get out of the way mid-flight by answering the presign with mode 'native'. 17// 18// The progress bar is updated by writing to the DOM directly, never through Blazor. Upload 19// progress fires many times a second and each round-trip would be a SignalR message carrying 20// a number no C# code reads. 21(function () { 22 if (window.gsUpload) return; 23 24 var STATE_CLASSES = [ 25 'is-shown', 'is-indeterminate', 'is-cancellable', 'is-done', 'is-error', 'is-precheck' 26 ]; 27 28 function mb(bytes) { return Math.round(bytes / 1024 / 1024); } 29 30 function formatBytes(sent, total) { 31 if (!total) return ''; 32 var oneMb = 1024 * 1024; 33 if (total < oneMb) return Math.round(sent / 1024) + ' KB of ' + Math.round(total / 1024) + ' KB'; 34 return (sent / oneMb).toFixed(1) + ' MB of ' + (total / oneMb).toFixed(1) + ' MB'; 35 } 36 37 function extensionOf(name) { 38 var dot = name.lastIndexOf('.'); 39 return dot < 0 ? '' : name.slice(dot).toLowerCase(); 40 } 41 42 // ---- view ------------------------------------------------------------------------- 43 44 function View(ids) { 45 this.ids = ids; 46 this.lastAnnounced = -1; 47 } 48 49 View.prototype.el = function (name) { return document.getElementById(this.ids[name]); }; 50 51 View.prototype.setState = function (state) { 52 var root = this.el('root'); 53 if (!root) return; 54 root.classList.remove.apply(root.classList, STATE_CLASSES); 55 if (state) root.classList.add('is-shown', state); 56 root.setAttribute('data-upload-state', state || 'idle'); 57 }; 58 59 View.prototype.hide = function () { this.setState(null); }; 60 61 View.prototype.setLabel = function (text) { 62 var node = this.el('label'); 63 if (node) node.textContent = text || ''; 64 }; 65 66 View.prototype.announce = function (text) { 67 var node = this.el('live'); 68 if (node) node.textContent = text || ''; 69 }; 70 71 View.prototype.preparing = function (text) { 72 this.setState('is-indeterminate'); 73 this.setLabel(text); 74 var pct = this.el('pct'); 75 if (pct) pct.textContent = ''; 76 var bytes = this.el('bytes'); 77 if (bytes) bytes.textContent = ''; 78 // Remove aria-valuenow: per ARIA, a progressbar without it is indeterminate, which is 79 // exactly what this phase is. 80 var track = this.el('track'); 81 if (track) track.removeAttribute('aria-valuenow'); 82 this.lastAnnounced = -1; 83 this.announce(text); 84 }; 85 86 View.prototype.progress = function (sent, total, label) { 87 var percent = total > 0 ? Math.min(100, Math.floor((sent / total) * 100)) : 0; 88 this.setState('is-cancellable'); 89 if (label) this.setLabel(label); 90 91 var fill = this.el('fill'); 92 if (fill) fill.style.width = percent + '%'; 93 94 var pct = this.el('pct'); 95 if (pct) pct.textContent = percent + '%'; 96 97 var bytes = this.el('bytes'); 98 if (bytes) bytes.textContent = formatBytes(sent, total); 99 100 var track = this.el('track'); 101 if (track) track.setAttribute('aria-valuenow', String(percent)); 102 103 // Announce only on a 10% boundary. Reciting every tick makes a screen reader unusable. 104 var bucket = Math.floor(percent / 10); 105 if (bucket !== this.lastAnnounced) { 106 this.lastAnnounced = bucket; 107 this.announce('Uploading, ' + percent + ' percent complete.'); 108 } 109 }; 110 111 View.prototype.done = function (text) { 112 this.progress(1, 1, text); 113 this.setState('is-done'); 114 var bytes = this.el('bytes'); 115 if (bytes) bytes.textContent = ''; 116 this.announce(text); 117 }; 118 119 View.prototype.error = function (message, precheck) { 120 this.setState('is-error'); 121 if (precheck) { 122 var root = this.el('root'); 123 if (root) root.classList.add('is-precheck'); 124 } 125 var msg = this.el('errorMsg'); 126 if (msg) msg.textContent = message; 127 this.announce(message); 128 }; 129 130 // ---- transfer --------------------------------------------------------------------- 131 132 // One PUT. Resolves on 2xx, rejects with a tagged error otherwise. Kept as XHR rather than 133 // fetch because fetch still has no upload-progress event. 134 function put(url, file, session, onProgress) { 135 return new Promise(function (resolve, reject) { 136 var xhr = new XMLHttpRequest(); 137 session.xhr = xhr; 138 xhr.open('PUT', url, true); 139 140 xhr.upload.onprogress = function (e) { 141 if (e.lengthComputable) onProgress(e.loaded); 142 }; 143 xhr.onload = function () { 144 session.xhr = null; 145 if (xhr.status >= 200 && xhr.status < 300) return resolve(); 146 var err = new Error('upload failed with status ' + xhr.status); 147 err.code = xhr.status === 413 ? 'too_large' : 'put_failed'; 148 reject(err); 149 }; 150 xhr.onerror = function () { 151 session.xhr = null; 152 var err = new Error('network error during upload'); 153 // Most commonly a missing bucket CORS policy: the browser blocks the response and 154 // reports an opaque network failure with no status. 155 err.code = 'network'; 156 reject(err); 157 }; 158 xhr.onabort = function () { 159 session.xhr = null; 160 var err = new Error('upload cancelled'); 161 err.code = 'aborted'; 162 reject(err); 163 }; 164 165 xhr.send(file); 166 }); 167 } 168 169 function postJson(url, body) { 170 return fetch(url, { 171 method: 'POST', 172 headers: { 'Content-Type': 'application/json' }, 173 body: JSON.stringify(body), 174 credentials: 'same-origin' 175 }); 176 } 177 178 function report(opts, payload) { 179 // Telemetry only; a failure to report must never surface. 180 postJson(opts.reportUrl, payload).catch(function () { }); 181 } 182 183 function failure(code, message, plannerFacing) { 184 var err = new Error(message); 185 err.code = code; 186 err.planner = !!plannerFacing; 187 return err; 188 } 189 190 // ---- controller ------------------------------------------------------------------- 191 192 function totalOf(files) { 193 var total = 0; 194 for (var i = 0; i < files.length; i++) total += files[i].size; 195 return total; 196 } 197
198 // Mirrors the server's presign checks so an obviously-doomed selection is refused without a 199 // round trip. The server still re-checks everything; this is purely for latency, which is 200 // why it is allowed to be a copy of rules that live in UploadLimits.cs. 201 function precheck(files, opts) { 202 if (!files.length) return 'Choose at least one file to upload.'; 203 if (files.length > opts.maxUploadFiles) { 204 return 'Too many files (limit ' + opts.maxUploadFiles + ').'; 205 } 206 for (var i = 0; i < files.length; i++) { 207 var ext = extensionOf(files[i].name); 208 if (opts.allowedExtensions.indexOf(ext) < 0) { 209 return "File type '" + ext + "' not allowed."; 210 } 211 } 212 var total = totalOf(files); 213 if (total > opts.maxBytes) { 214 return 'Upload exceeds ' + mb(opts.maxBytes) + ' MB limit.'; 215 } 216 // Only a single .zip escapes the inline-assembly cap - it is promoted by a server-side 217 // copy. Anything else over the cap (several files, OR one big non-zip) would be streamed 218 // into an archive while the submit request blocks, which Cloudflare times out. 219 var singleZip = files.length === 1 && extensionOf(files[0].name) === '.zip'; 220 if (!singleZip && total > opts.maxInlineAssemblyBytes) { 221 return 'For uploads over ' + mb(opts.maxInlineAssemblyBytes) + 222 ' MB, please combine your documents into a single .zip file.'; 223 } 224 return null; 225 } 226 227 async function requestPlan(opts, files) { 228 var manifest = files.map(function (f) { return { fileName: f.name, size: f.size }; }); 229 var res = await postJson(opts.presignUrl, { files: manifest }); 230 231 // An expired cookie makes RequireAuthorization challenge, and fetch follows the resulting 232 // 302 to the login page transparently - so this arrives as a perfectly happy 200 of HTML. 233 // Without this check it would surface as a generic "check your connection", leaving the 234 // planner with no idea they simply need to sign in again. 235 var contentType = res.headers.get('content-type') || ''; 236 if (res.redirected || contentType.indexOf('application/json') < 0) { 237 throw failure('signed_out', 'Your session has expired. Please sign in again and retry.', true); 238 } 239 240 var body = await res.json().catch(function () { return {}; }); 241 if (!res.ok) { 242 throw failure(body.error || 'presign_failed', body.message || 'Could not start the upload.', !!body.message); 243 } 244 return body; 245 } 246 247 async function uploadOne(state, entry, file, label, sentBefore, total) { 248 var opts = state.opts; 249 var view = state.view; 250 var onProgress = function (loaded) { view.progress(sentBefore + loaded, total, label); }; 251 252 try { 253 return await put(entry.directUrl || entry.originUrl, file, state.session, onProgress); 254 } catch (err) { 255 // A direct PUT can fail for reasons the origin path would survive, most often a missing 256 // bucket CORS policy. Retry through our own endpoint - but only when the file is small 257 // enough for Cloudflare to carry it, otherwise the retry just earns a 413 and buries the 258 // real cause behind a second, less useful failure. 259 var recoverable = entry.directUrl 260 && err.code !== 'aborted' 261 && file.size <= opts.maxOriginBodyBytes; 262 if (!recoverable) throw err; 263 264 // The usual cause fails every file identically, so report it once per submission rather 265 // than once per file - otherwise one broken-CORS 100-file submit is 100 Slack messages. 266 if (!state.session.reportedFallback) { 267 state.session.reportedFallback = true; 268 state.mode = 'origin-fallback'; 269 report(opts, { 270 code: err.code, message: 'direct PUT failed, retrying via origin', 271 totalBytes: total, fileCount: 1, mode: 'direct' 272 }); 273 } 274 return await put(entry.originUrl, file, state.session, onProgress); 275 } 276 } 277 278 async function runUpload(state) { 279 var opts = state.opts; 280 var view = state.view; 281 var files = Array.prototype.slice.call(state.fileInput.files || []); 282 var total = totalOf(files); 283 284 var problem = precheck(files, opts); 285 if (problem) { 286 view.error(problem, true); 287 state.busy = false;
288 return; 289 } 290 291 view.preparing('Preparing upload...'); 292 293 try { 294 var plan = await requestPlan(opts, files); 295 state.mode = plan.mode; 296 297 // The server can decline to stage - e.g. direct upload is off and the file is too big 298 // for our own endpoint but still within what Cloudflare will carry on a plain form post. 299 // Getting out of the way is strictly better than refusing something that used to work. 300 if (plan.mode === 'native') { 301 state.form.submit(); 302 return; 303 } 304 305 var sentBefore = 0; 306 // Sequential rather than parallel: the progress bar reads as one transfer, and a large 307 // bundle saturates the uplink anyway so concurrency buys nothing. 308 for (var i = 0; i < plan.files.length; i++) { 309 if (state.session.cancelled) throw failure('aborted', 'upload cancelled'); 310 311 var entry = plan.files[i]; 312 var file = files[entry.index]; 313 var label = files.length > 1 314 ? 'Uploading ' + file.name + ' (' + (i + 1) + ' of ' + files.length + ')' 315 : 'Uploading ' + file.name; 316 317 await uploadOne(state, entry, file, label, sentBefore, total); 318 sentBefore += file.size; 319 view.progress(sentBefore, total, label); 320 } 321 322 view.done('Upload complete. Preparing your report...'); 323 324 // Excluding the file input from the submission is load-bearing: leave it enabled and the 325 // browser re-sends every byte through Cloudflare on the metadata POST, which is the exact 326 // failure this whole path exists to avoid. 327 state.fileInput.disabled = true; 328 329 var hidden = state.form.querySelector('input[name="stagedUpload"]'); 330 if (!hidden) { 331 hidden = document.createElement('input'); 332 hidden.type = 'hidden'; 333 hidden.name = 'stagedUpload'; 334 state.form.appendChild(hidden); 335 } 336 hidden.value = JSON.stringify(plan.files.map(function (f) { return f.key; })); 337 338 // form.submit(), NOT requestSubmit(): submit() does not fire the submit event, so we do 339 // not re-enter this handler. The form must post natively for the endpoint's 302 to 340 // navigate the browser. 341 state.form.submit(); 342 } catch (err) { 343 state.busy = false; 344 state.fileInput.disabled = false; 345 346 if (err && err.code === 'aborted') { 347 view.hide(); 348 return; 349 } 350 351 view.error( 352 err && err.planner 353 ? err.message 354 : 'Your documents could not be uploaded. Check your connection and try again.', 355 false); 356 357 report(opts, { 358 code: (err && err.code) || 'unknown', 359 message: (err && err.message) || String(err), 360 totalBytes: total, 361 fileCount: files.length, 362 mode: state.mode || 'unknown' 363 }); 364 } 365 } 366 367 function start(state) { 368 if (state.busy) return; 369 state.busy = true; 370 state.session = { xhr: null, cancelled: false, reportedFallback: false }; 371 state.fileInput.disabled = false; 372 runUpload(state); 373 } 374 375 // ---- public surface --------------------------------------------------------------- 376 377 window.gsUpload = { 378 attach: function (form, fileInput, opts) { 379 if (!form || !fileInput || form._gsUpload) return; 380 // Limits and element ids are supplied by the component from SelfSubmitOptions and 381 // UploadProgressIds. No defaults here on purpose: a second copy of the configuration 382 // would silently diverge from the server's, and the native POST is a safe thing to fall 383 // back to if this is ever called wrong. 384 if (!opts || !opts.ids || !opts.allowedExtensions) return; 385 386 var state = { 387 form: form, 388 fileInput: fileInput, 389 opts: { 390 presignUrl: opts.presignUrl || '/planner/api/uploads/presign', 391 reportUrl: opts.reportUrl || '/planner/api/uploads/report-failure', 392 maxBytes: opts.maxBytes, 393 maxOriginBodyBytes: opts.maxOriginBodyBytes, 394 maxInlineAssemblyBytes: opts.maxInlineAssemblyBytes, 395 maxUploadFiles: opts.maxUploadFiles, 396 allowedExtensions: opts.allowedExtensions 397 }, 398 view: new View(opts.ids), 399 session: { xhr: null, cancelled: false, reportedFallback: false }, 400 busy: false, 401 mode: null 402 }; 403 404 state.onSubmit = function (e) { 405 if (state.busy) { e.preventDefault(); return; } 406 if (!state.fileInput.files || !state.fileInput.files.length) return; // let native validation speak 407 e.preventDefault(); 408 start(state); 409 }; 410 411 state.onCancel = function () { 412 state.session.cancelled = true; 413 if (state.session.xhr) state.session.xhr.abort(); 414 }; 415 416 state.onRetry = function () { start(state); }; 417 418 form.addEventListener('submit', state.onSubmit); 419 var cancelBtn = state.view.el('cancel'); 420 if (cancelBtn) cancelBtn.addEventListener('click', state.onCancel); 421 var retryBtn = state.view.el('retry'); 422 if (retryBtn) retryBtn.addEventListener('click', state.onRetry); 423 424 form._gsUpload = state; 425 }, 426 427 detach: function (form) { 428 var state = form && form._gsUpload; 429 if (!state) return; 430 if (state.session && state.session.xhr) state.session.xhr.abort(); 431 form.removeEventListener('submit', state.onSubmit); 432 var cancelBtn = state.view.el('cancel'); 433 if (cancelBtn) cancelBtn.removeEventListener('click', state.onCancel); 434 var retryBtn = state.view.el('retry'); 435 if (retryBtn) retryBtn.removeEventListener('click', state.onRetry); 436 form._gsUpload = null; 437 } 438 }; 439})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.