PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/utils/reportSubmit.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:23 UTC 7,858 bytes, 178 lines download raw bytes

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.