PageSourceSearch

https://planning.gapsensehq.com/js/upload.en3n9x8l78.js

js gapsensehq.com collected 2026-10-08 12:24:08 UTC 16,668 bytes, 439 lines download raw bytes

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.