Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/report.js

15.2 KiB, 5 runs

created by r2519314175:1433, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/// report.js -- reporting one message, and only one message.
2///
3/// **The operator cannot read messages.** So when somebody is abused here there
4/// is no log for anybody to look at: the only person who can produce the words
5/// is the person they were sent to, and this file is them choosing to.
6///
7/// ## What leaves, and what does not
8///
9/// Four things go, and they are named on screen before anything moves:
10///
11/// 1. the SIGNED MESSAGE -- the artefact, exactly as it arrived, carrying the
12/// sender's signature over its own address;
13/// 2. the CIPHERTEXT -- the sealed envelope the relay carried, byte for byte;
14/// 3. the CONTENT KEY for that one envelope;
15/// 4. a reason, from the closed list the gateway serves.
16///
17/// The content key opens THAT envelope and nothing else. It is not the sealing
18/// key of this device, it does not open the conversation, and it does not open
19/// the next message from the same person. That granularity is the whole reason
20/// this can exist without weakening the seal.
21///
22/// ## The one screen rule, borrowed from improve.js verbatim
23///
24/// **What leaves is exactly what is on screen at that moment.** So the sheet
25/// does not draw the message out of the panel's own record; it decodes the
26/// ARTEFACT it is about to upload and draws the body out of that. A screen
27/// showing one string while another travels would be the worst possible defect
28/// in this particular file, and this removes the possibility rather than
29/// guarding against it.
30///
31/// ## What this file deliberately does not do
32///
33/// It does not decode, verify or re-address anything itself. The artefact is
34/// read by `DaimondCrypto.read`, which is the format's own crate compiled to
35/// wasm and the same reader post.js uses; a second reader written here would be
36/// a second opinion about what a message says, in the one place where two
37/// opinions must be impossible. It also does not block, hide or delete: those
38/// are the panel's and they work whether or not anything is ever reported.
39(function () {
40 'use strict';
41
42 /// The endpoint. A path of its own and not an `?op=` on the relay, because
43 /// the relay's whole contract is that it cannot read what it carries, and
44 /// this is the one place a message reaches the gateway on purpose.
45 var API = '/api/report';
46
47 /// The reasons, once the gateway has been asked. Null until then.
48 var _reasons = null;
49
50 /// Whatever sheet is open, so a second press does not stack two.
51 var _open = null;
52
53 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
54
55 /// A translated string, or the English written here when the key is missing.
56 /// The same helper post.js carries, and for the same reason: a build whose
57 /// locale files have not caught up shows English rather than a key.
58 function tOr(k, fallback, v) {
59 var s = t(k, v);
60 if (s !== k) return s;
61 if (!v) return String(fallback);
62 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
63 return Object.prototype.hasOwnProperty.call(v, name) ? String(v[name]) : whole;
64 });
65 }
66
67 function log(/* ...args */) {
68 if (window.DaimondDebug) {
69 console.log.apply(console, ['[report]'].concat([].slice.call(arguments)));
70 }
71 }
72
73 // ── Encoding ───────────────────────────────────────────────
74
75 function b64dec(str) {
76 var bin = atob(String(str));
77 var out = new Uint8Array(bin.length);
78 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
79 return out;
80 }
81
82 // ── What a report is made of ───────────────────────────────
83
84 /// The parts of a message this file needs, or the sentence saying which one
85 /// is missing.
86 ///
87 /// **Three fields post.js must keep on a collected message**, and they are
88 /// named here rather than assumed because a build that does not keep them
89 /// cannot report anything and must say so plainly:
90 ///
91 /// * `art` -- the signed artefact, base64, as it came out of the envelope.
92 /// Without it there is no signature, and a report with no signature is
93 /// an accusation.
94 /// * `env` -- the sealed envelope, base64, byte for byte as the relay
95 /// handed it over.
96 /// * `ck` -- the content key that opened it, base64.
97 ///
98 /// A message that has none of them is not reportable, and the honest thing
99 /// to do about that is refuse and say why. Filing three quarters of a report
100 /// would put a person's words on the operator's disk while giving them
101 /// nothing to check them against, which is the exact failure the design
102 /// exists to prevent.
103 function partsOf(m) {
104 if (!m || typeof m !== 'object') {
105 return { why: tOr('report.err_no_message',
106 'That message is not one this device holds.') };
107 }
108 if (!m.art) {
109 return { why: tOr('report.err_no_artefact',
110 'This build did not keep the signed form of that message, so there is '
111 + 'nothing to prove who sent it. A report without it would be an '
112 + 'accusation, so nothing was sent.') };
113 }
114 if (!m.env || !m.ck) {
115 return { why: tOr('report.err_no_envelope',
116 'This build did not keep the sealed form of that message, so the report '
117 + 'could not be checked against what the relay carried. Nothing was sent.') };
118 }
119 return { art: String(m.art), env: String(m.env), ck: String(m.ck) };
120 }
121
122 /// Whether a message can be reported at all, for a caller drawing a button.
123 ///
124 /// A control that exists only to produce an error explains less than its
125 /// absence does, so post.js asks this before it draws one.
126 function canReport(m) {
127 return !!(m && m.art && m.env && m.ck && m.dir !== 'out' && !m.bad);
128 }
129
130 /// The body inside an artefact, read by the format's own crate.
131 ///
132 /// Throws with a sentence a person can read. This is the string the sheet
133 /// draws AND the bytes it uploads are the ones it came from, which is the
134 /// one-screen rule made structural.
135 function bodyOf(artB64) {
136 var b = window.DaimondCrypto;
137 if (!b || typeof b.read !== 'function') {
138 throw new Error(tOr('report.err_no_bridge',
139 'This build cannot read the message it is about to send, so it will not '
140 + 'send it.'));
141 }
142 var got = JSON.parse(b.read(b64dec(artB64)));
143 if (got.kind !== 'post') {
144 throw new Error(tOr('report.err_not_a_post',
145 'That is not a message; it is a {kind}.', { kind: String(got.kind || '?') }));
146 }
147 return {
148 body: String((got.post && got.post.body) || ''),
149 addr: String(got.address || ''),
150 fp: String(got.fingerprint || ''),
151 };
152 }
153
154 // ── The gateway ────────────────────────────────────────────
155
156 /// The reasons the gateway will accept, fetched once.
157 ///
158 /// Asked rather than compiled in, so the picker and the endpoint cannot
159 /// drift: a client offering a sixth reason would have every report refused
160 /// and the person filing it told nothing useful about why.
161 async function reasons() {
162 if (_reasons) return _reasons;
163 var r = await fetch(API, {
164 credentials: 'same-origin',
165 headers: { 'x-daimond-api': String(DaimondGateway.clientApi()) },
166 });
167 var j = null;
168 try { j = await r.json(); } catch (e) { j = null; }
169 if (!r.ok || !j || j.ok !== true || !Array.isArray(j.reasons)) {
170 throw new Error(tOr('report.err_no_reasons',
171 'Reporting is not available just now.'));
172 }
173 _reasons = j.reasons.map(String);
174 return _reasons;
175 }
176
177 /// Send one report. Answers `{ok, fresh}` or throws with a sentence.
178 ///
179 /// `parts` is what [`partsOf`] returned; `reason` is one of [`reasons`].
180 async function send(parts, reason) {
181 var r = await fetch(API, {
182 method: 'POST',
183 credentials: 'same-origin',
184 headers: { 'Content-Type': 'application/json', 'x-daimond-api': String(DaimondGateway.clientApi()) },
185 body: JSON.stringify({
186 artefact: parts.art,
187 envelope: parts.env,
188 ckey: parts.ck,
189 reason: String(reason),
190 }),
191 });
192 var j = null;
193 try { j = await r.json(); } catch (e) { j = null; }
194 if (!r.ok || !j || j.ok !== true) {
195 throw new Error((j && j.error) || tOr('report.err_failed',
196 'That report was not filed. Nothing was sent.'));
197 }
198 return { ok: true, fresh: j.fresh === true };
199 }
200
201 // ── The sheet ──────────────────────────────────────────────
202
203 function elt(tag, cls, text) {
204 var e = document.createElement(tag);
205 if (cls) e.className = cls;
206 if (text !== undefined) e.textContent = text;
207 return e;
208 }
209
210 /// Close whatever is open, and let go of it.
211 function close() {
212 if (_open && _open.parentNode) _open.parentNode.removeChild(_open);
213 _open = null;
214 }
215
216 /// Open the confirmation sheet for one message.
217 ///
218 /// Everything a person needs to decide is on this one screen: the words
219 /// that will travel, the sentence saying what else travels with them, and
220 /// the sentence saying what does NOT. The last of those is not decoration.
221 /// Somebody deciding whether to report abuse is deciding how much of their
222 /// own life to hand over, and "the rest of this conversation stays sealed"
223 /// is the fact that decision turns on.
224 async function open(m) {
225 close();
226 var parts = partsOf(m);
227 if (parts.why) {
228 alertLine(parts.why);
229 return null;
230 }
231 var read;
232 try { read = bodyOf(parts.art); }
233 catch (e) { alertLine(String((e && e.message) || e)); return null; }
234
235 var list;
236 try { list = await reasons(); }
237 catch (e) { alertLine(String((e && e.message) || e)); return null; }
238
239 var wrap = elt('div', 'modal');
240 wrap.id = 'report-sheet';
241 var card = elt('div', 'modal-card');
242 card.appendChild(elt('h2', '', tOr('report.title', 'Report this message')));
243
244 card.appendChild(elt('p', 'report-rule', tOr('report.rule',
245 'These exact words go to the operator, with the sender’s signature and '
246 + 'the one key that opens this message. Nothing else from this conversation '
247 + 'goes: not the rest of the thread, not their other messages, not your '
248 + 'other conversations.')));
249
250 // The words themselves, out of the bytes that are about to travel.
251 var body = elt('blockquote', 'post-body report-body', read.body);
252 body.id = 'report-body';
253 card.appendChild(body);
254
255 var who = elt('p', 'post-fp report-fp', tOr('report.signed_by',
256 'Signed by {fp}', { fp: read.fp || '?' }));
257 card.appendChild(who);
258
259 // The reasons, as radios: a closed list, and the gateway refuses
260 // anything else, so a free box would be a box whose contents are
261 // thrown away.
262 var group = elt('div', 'report-reasons');
263 group.setAttribute('role', 'radiogroup');
264 group.setAttribute('aria-label', tOr('report.why', 'Why are you reporting it?'));
265 list.forEach(function (r, i) {
266 var lab = elt('label', 'report-reason');
267 var inp = document.createElement('input');
268 inp.type = 'radio';
269 inp.name = 'report-reason';
270 inp.value = r;
271 inp.checked = i === 0;
272 lab.appendChild(inp);
273 lab.appendChild(elt('span', '', tOr('report.reason_' + r, r)));
274 group.appendChild(lab);
275 });
276 card.appendChild(group);
277
278 var status = elt('p', 'report-status');
279 status.setAttribute('role', 'status');
280 card.appendChild(status);
281
282 var acts = elt('div', 'post-acts');
283 var go = elt('button', 'post-btn report-send',
284 tOr('report.send', 'Send this report'));
285 go.type = 'button';
286 var no = elt('button', 'post-btn report-cancel', tOr('report.cancel', 'Cancel'));
287 no.type = 'button';
288 acts.appendChild(go);
289 acts.appendChild(no);
290 card.appendChild(acts);
291
292 no.addEventListener('click', close);
293 go.addEventListener('click', async function () {
294 var picked = group.querySelector('input:checked');
295 go.disabled = true;
296 status.textContent = tOr('report.sending', 'Sending…');
297 try {
298 var out = await send(parts, picked ? picked.value : list[0]);
299 status.textContent = out.fresh
300 ? tOr('report.sent', 'Reported. The operator can now read this one message.')
301 : tOr('report.already',
302 'You have already reported this message. Nothing new was sent.');
303 go.remove();
304 no.textContent = tOr('report.done', 'Close');
305 } catch (e) {
306 go.disabled = false;
307 status.textContent = String((e && e.message) || e);
308 }
309 });
310
311 wrap.appendChild(card);
312 document.body.appendChild(wrap);
313 _open = wrap;
314 go.focus();
315 return wrap;
316 }
317
318 /// One line said where a sheet cannot be opened at all.
319 ///
320 /// Deliberately not a `confirm()` or a silent return: somebody who pressed
321 /// Report and saw nothing happen will press it again, and then conclude
322 /// that reporting does not work.
323 function alertLine(msg) {
324 close();
325 var wrap = elt('div', 'modal');
326 wrap.id = 'report-sheet';
327 var card = elt('div', 'modal-card');
328 card.appendChild(elt('h2', '', tOr('report.title', 'Report this message')));
329 card.appendChild(elt('p', 'report-status', String(msg)));
330 var no = elt('button', 'post-btn report-cancel', tOr('report.done', 'Close'));
331 no.type = 'button';
332 no.addEventListener('click', close);
333 card.appendChild(no);
334 wrap.appendChild(card);
335 document.body.appendChild(wrap);
336 _open = wrap;
337 no.focus();
338 }
339
340 // ── The one thing another panel has to do ──────────────────
341 //
342 // A delegated listener, so post.js adds ONE attribute to a row's control and
343 // nothing else. It does not reach into that panel's DOM, does not decorate
344 // its rows, and does not run on any element that does not ask for it.
345
346 /// The message a control names, out of whatever the panel holds.
347 function find(addr) {
348 if (!window.DaimondPost) return null;
349 var all = [].concat(DaimondPost.list() || [], DaimondPost.tray() || []);
350 for (var i = 0; i < all.length; i++) {
351 if (String(all[i].addr) === String(addr)) return all[i];
352 }
353 return null;
354 }
355
356 document.addEventListener('click', function (e) {
357 var btn = e.target && e.target.closest && e.target.closest('[data-report-addr]');
358 if (!btn) return;
359 e.preventDefault();
360 var addr = btn.getAttribute('data-report-addr');
361 var m = find(addr);
362 if (!m) {
363 alertLine(tOr('report.err_no_message',
364 'That message is not one this device holds.'));
365 return;
366 }
367 open(m).then(null, function (err) { log('sheet failed', err); });
368 });
369
370 // Escape closes it, on the rule every other overlay in this app follows.
371 document.addEventListener('keydown', function (e) {
372 if (e.key === 'Escape' && _open) close();
373 });
374
375 // ── Public surface ─────────────────────────────────────────
376 window.DaimondReport = {
377 /// Whether a message can be reported, for a caller drawing a control.
378 canReport: canReport,
379 /// The parts of a message a report is made of, or `{why}`.
380 partsOf: partsOf,
381 /// The reasons the gateway accepts. Fetched once, then held.
382 reasons: reasons,
383 /// Open the confirmation sheet for one message record.
384 open: open,
385 /// Close whatever is open.
386 close: close,
387 /// File one without a sheet, for a verifier. `parts` is `partsOf`'s
388 /// answer; nothing here is a shortcut past the one-screen rule, since a
389 /// caller reaching this has drawn its own screen or is a test.
390 send: send,
391 /// The body inside an artefact, as the sheet draws it.
392 bodyOf: bodyOf,
393 /// Everything this module would say if asked.
394 state: function () {
395 return { open: !!_open, reasons: _reasons ? _reasons.slice() : null };
396 },
397 };
398})();