1// Submission client for the branded in-archive report form (#509). 2// 3// The archive is a static site with no server of its own, so a reader's report 4// is POSTed to the Apps Script web app (automation/apps-script/Code.gs), which 5// authenticates to GitHub as a GitHub App and files the issue. The reader never 6// needs a GitHub account and never leaves the site. This module owns the 7// request/response contract shared with that handler and is kept DOM-free so it 8// unit-tests without a browser. 9// 10// CORS note: Apps Script web apps answer simple cross-origin requests and set 11// Access-Control-Allow-Origin: *, but they cannot answer a CORS preflight (there 12// is no doOptions). So the POST MUST stay a "simple request": text/plain body, 13// no custom headers. Sending application/json would trigger a preflight the 14// endpoint cannot satisfy, and the submit would fail in the browser. 15 16// Discriminator the doPost router keys off to tell a report from any other 17// future POST kind. MUST match KIND_REPORT in Code.gs. 18export const REPORT_KIND = 'report'; 19 20// Field length caps. These are UX hints enforced authoritatively server-side; 21// keeping them here lets the form warn before a wasted round-trip. 22export const LIMITS = { 23 whatHappened: 5000, 24 expected: 2000, 25 steps: 3000, 26 url: 2000, 27 title: 300, 28 why: 3000, 29 email: 254, 30}; 31 32const trim = (v) => (typeof v === 'string' ? v.trim() : ''); 33 34/** 35 * A per-report idempotency key: unique per report, stable across the reader's 36 * retries of it (the modal regenerates it only when it reopens). The server 37 * dedupes on this key, so a retry after a slow-but-successful submit that the 38 * browser aborted does not file a second issue. crypto.randomUUID is available 39 * in every browser the archive targets (secure context, and localhost counts); 40 * the fallback keeps tests and any non-secure context working. 41 * @returns {string} 42 */ 43export const newReportKey = () => 44 (typeof crypto !== 'undefined' && crypto.randomUUID) 45 ? crypto.randomUUID() 46 : 'r-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); 47 48/** 49 * Build the JSON payload sent to the endpoint. `context` carries the 50 * auto-captured page/version/browser so the reporter never types them. 51 * `honeypot` is the hidden anti-spam field: real users leave it empty. 52 * `idempotencyKey` lets the server dedupe a retried report; see newReportKey. 53 * @returns {Object} the wire payload 54 */ 55export const buildReportPayload = ({ intent, fields = {}, context = {}, honeypot = '', idempotencyKey = '' } = {}) => ({ 56 kind: REPORT_KIND, 57 intent: intent === 'record' ? 'record' : 'problem', 58 whatHappened: trim(fields.whatHappened), 59 expected: trim(fields.expected), 60 steps: trim(fields.steps), 61 url: trim(fields.url), 62 title: trim(fields.title), 63 why: trim(fields.why), 64 email: trim(fields.email), 65 // Honeypot travels under an innocuous name a bot is likely to autofill. 66 website: trim(honeypot), 67 page: trim(context.page), 68 version: trim(context.version), 69 browser: trim(context.browser), 70 idempotencyKey: trim(idempotencyKey), 71}); 72 73/** 74 * Client-side validation so the form can show a message before submitting. 75 * The server re-validates; this only saves an obviously-bad round-trip. 76 * @returns {{valid: boolean, error: string}} 77 */ 78export const validateReport = (payload) => { 79 if (!payload || typeof payload !== 'object') { 80 return { valid: false, error: 'Something went wrong. Please try again.' }; 81 } 82 if (payload.intent === 'record') { 83 if (!payload.url) { 84 return { valid: false, error: 'Please paste the link to the work you want to suggest.' }; 85 } 86 if (!/^https?:\/\//i.test(payload.url)) { 87 return { valid: false, error: 'That link needs to start with http:// or https://.' }; 88 } 89 } else if (!payload.whatHappened) { 90 return { valid: false, error: 'Please describe what happened so we can look into it.' }; 91 } 92 if (payload.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(payload.email)) { 93 return { valid: false, error: 'That email address does not look right. You can also leave it blank.' }; 94 } 95 for (const [field, cap] of Object.entries(LIMITS)) { 96 if (typeof payload[field] === 'string' && payload[field].length > cap) { 97 return { valid: false, error: `That ${field === 'whatHappened' ? 'description' : field} is too long (max ${cap} characters).` }; 98 } 99 } 100 return { valid: true, error: '' }; 101}; 102 103/** 104 * POST a report to the endpoint. Returns a plain result the modal renders: 105 * { ok: true, issueUrl } filed; link to the created issue 106 * { ok: true, issueUrl: '' } accepted, but the URL was not readable 107 * { ok: false, fallback: true } no endpoint configured, or the submission was 108 * honeypot-dropped; caller should fall back to 109 * the GitHub deep link so nothing is lost 110 * { ok: false, error } validation/network/server failure 111 * 112 * A missing/blank `endpoint` is not an error: it is the pre-deploy state, where 113 * the form degrades to the existing GitHub issue deep link so nothing breaks 114 * before the Apps Script web app is live. 115 */ 116export const submitReport = async ({ endpoint, payload, fetchImpl, timeoutMs = 15000 } = {}) => { 117 if (!endpoint) return { ok: false, fallback: true }; 118 119 const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
120 if (!doFetch) return { ok: false, error: 'No network available.' }; 121 122 // Abort a hung request so the form never spins forever. AbortController exists 123 // in every browser the archive targets and in Node 18+ (test env). 124 const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; 125 const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; 126 127 try { 128 const res = await doFetch(endpoint, { 129 method: 'POST', 130 // text/plain keeps this a simple request (no preflight); see CORS note. 131 headers: { 'Content-Type': 'text/plain;charset=utf-8' }, 132 body: JSON.stringify(payload), 133 redirect: 'follow', 134 signal: controller ? controller.signal : undefined, 135 }); 136 137 const raw = await res.text(); 138 let body = null; 139 try { 140 body = raw ? JSON.parse(raw) : null; 141 } catch { 142 body = null; 143 } 144 145 if (!res.ok) { 146 const error = (body && body.error) || 'The server could not accept your report. Please try again later.'; 147 return { ok: false, error }; 148 } 149 // Success requires a positive ok:true. A 2xx with an empty or non-JSON body 150 // (e.g. a misconfigured endpoint URL that returns HTML) must NOT read as 151 // filed, or endpoint misconfiguration becomes silent data loss. 152 if (!body || typeof body.ok === 'undefined') { 153 return { ok: false, error: 'The archive sent an unexpected response. Please try again later.' }; 154 } 155 if (body.ok === false) { 156 return { ok: false, error: body.error || 'Your report could not be filed.' }; 157 } 158 // The server drops a honeypot hit as ok:true (no dropped issue is filed) so a 159 // bot gets no retry signal. A real user whose autofill populated the hidden 160 // field would otherwise see a false success with nothing filed, so route them 161 // to the GitHub fallback where their report survives. Bots POST without this 162 // client and stay silently dropped server-side, so the anti-spam value holds. 163 if (body.dropped) { 164 return { ok: false, fallback: true }; 165 } 166 return { ok: true, issueUrl: body.issueUrl || '' }; 167 } catch (err) { 168 const aborted = err && err.name === 'AbortError'; 169 return { 170 ok: false, 171 error: aborted 172 ? 'That took too long. Please check your connection and try again.' 173 : 'Could not reach the archive to send your report. Please try again later.', 174 }; 175 } finally { 176 if (timer) clearTimeout(timer); 177 } 178};
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.