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 | })(); |