1// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2// The server sends a proof of work with the form; this script starts the
3// worker (pow-worker.js) when the page loads, so it solves while the visitor
4// writes, shows the length counters, and enables the button once the solution
5// is found and the minimum fill time has passed. Without scripts the form
6// stays hidden (the page says why).
7//
8// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9window.ermiteIssueForm = (() => {
10 const core = {
11 // counterClass is the class of a length counter: "over" above max, "warn"
12 // from 90 % of it, else none.
13 counterClass(n, max) {
14 if (n > max) return 'over';
15 if (n * 10 >= max * 9) return 'warn';
16 return '';
17 },
18 // length counts code points, like the server.
19 length(s) { return [...String(s)].length; },
20 // serverLength is the length the server will see for field ('title',
21 // 'body' or 'author'): NFC, control and bidi characters removed (body
22 // keeps \n and \t), title and author trimmed (domain.cleanText).
23 serverLength(field, value) {
24 const multiline = field === 'body';
25 let s = String(value).normalize('NFC');
26 if (multiline) s = s.replace(/\r\n/g, '\n');
27 let out = '';
28 for (const ch of s) {
29 const c = ch.codePointAt(0);
30 if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31 if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32 if (c === 0x200e || c === 0x200f || c === 0x061c ||
33 (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34 out += ch;
35 }
36 if (!multiline) out = out.trim();
37 return [...out].length;
38 },
39 draftKey(path) { return 'ermite:issue-draft:' + path; },
40 // saveDraft keeps the text fields in storage (never the solution).
41 saveDraft(storage, key, draft) {
42 try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43 },
44 // restoreDraft returns the saved draft, or null, and clears it.
45 restoreDraft(storage, key) {
46 try {
47 const raw = storage.getItem(key);
48 if (raw === null || raw === undefined) return null;
49 storage.removeItem(key);
50 const d = JSON.parse(raw);
51 if (!d || typeof d !== 'object') return null;
52 return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53 } catch (_) { return null; }
54 },
55 // onPageShow decides what to do on pageshow: a page restored from the
56 // back/forward cache has a challenge that may be spent or expired.
57 onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58 // state is what the button and the status line show.
59 // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60 state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61 if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62 if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63 if (sending) return { disabled: true, status: 'Sending…' };
64 if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65 if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66 if (overs) return { disabled: true, status: 'A field is too long.' };
67 if (empty) return { disabled: true, status: 'Enter a title.' };
68 return { disabled: false, status: 'Ready.' };
69 },
70 };
71
72 function init(form) {
73 const $ = (sel) => form.querySelector(sel);
74 const button = $('button[type="submit"]');
75 const status = $('[data-status]');
76 const solution = $('input[name="solution"]');
77 const minAt = Number(form.dataset.minSubmitAt) || 0;
78 const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79 let timer = null;
80
81 function render() {
82 const s = core.state({ ...ctx, now: Date.now(), minAt });
83 button.disabled = s.disabled;
84 if (status) status.textContent = s.status;
85 clearTimeout(timer);
86 if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87 }
88
89 const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90 const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91 const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92 if (storage) {
93 const d = core.restoreDraft(storage, draftKey);
94 if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95 }
96 window.addEventListener('pageshow', (e) => {
97 if (core.onPageShow(e.persisted) !== 'reload') return;
98 // The old challenge may be spent or expired: never reuse its solution.
99 if (button) button.disabled = true;
100 if (storage) {
101 core.saveDraft(storage, draftKey, {
102 title: fields.title ? fields.title.value : '',
103 body: fields.body ? fields.body.value : '',
104 author: fields.author ? fields.author.value : '',
105 });
106 }
107 window.location.reload();
108 });
109
110 const outs = [...form.querySelectorAll('output[data-for]')];
111 for (const out of outs) {
112 const field = form.querySelector('#' + out.dataset.for);
113 const name = field.name;
114 const max = Number(out.dataset.max);
115 const update = () => {
116 const n = core.serverLength(name, field.value);
117 out.textContent = n + ' / ' + max;
118 out.className = core.counterClass(n, max);
119 ctx.overs = outs.some((o) => o.className === 'over');
120 if (name === 'title') ctx.empty = n === 0;
121 render();
122 };
123 field.addEventListener('input', update);
124 update();
125 }
126
127 form.addEventListener('submit', (e) => {
128 if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129 ctx.sending = true;
130 render();
131 });
132
133 form.hidden = false;
134 form.removeAttribute('hidden');
135
136 if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137 ctx.unsupported = true;
138 render();
139 return;
140 }
141 render();
142 let worker;
143 try {
144 worker = new window.Worker(form.dataset.worker);
145 } catch (_) {
146 ctx.unsupported = true;
147 render();
148 return;
149 }
150 worker.onmessage = (e) => {
151 const m = e.data || {};
152 if (m.solution !== undefined) {
153 solution.value = m.solution;
154 ctx.solved = true;
155 worker.terminate();
156 } else if (m.error === 'nocrypto') {
157 ctx.unsupported = true;
158 } else if (m.error) {
159 ctx.failed = true;
160 } else {
161 return;
162 }
163 render();
164 };
165 worker.onerror = () => { ctx.failed = true; render(); };
166 worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167 }
168
169 return { core, init };
170})();
171
172document.addEventListener('DOMContentLoaded', () => {
173 const form = document.querySelector('form[data-issue-form]');
174 if (form) window.ermiteIssueForm.init(form);
175});