oxedyne/daimond/www/js/share.js
70.3 KiB, 1 run
created by r2519314175:1439, 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 | /* ============================================================ |
| 2 | Daimond — sharing a Diamond (share.js) |
| 3 | ------------------------------------------------------------ |
| 4 | Giving somebody a copy of something you keep. The Diamond's |
| 5 | files travel inside a signed `daimond/share/0` payload, the |
| 6 | payload is sealed to the recipient's key, and what lands on |
| 7 | their machine is THEIRS. |
| 8 | |
| 9 | ── WHAT A SHARE IS, AND WHAT IT IS NOT ───────────────────── |
| 10 | |
| 11 | 1. A SHARE IS A COPY THE RECEIVER OWNS. It is re-sealed to |
| 12 | their key, it lands in their workspace as a Diamond of |
| 13 | their own, and they may change it. You never see their |
| 14 | changes and they never see yours. It is NOT a live view: |
| 15 | there is no content key that outlives an edit, nothing to |
| 16 | revoke, no sync fan-out to anybody but the owner, and |
| 17 | nobody's storage in question but the receiver's own. |
| 18 | |
| 19 | 2. DATA TRAVELS FREELY; CODE TRAVELS ONLY BY CONSENT. A |
| 20 | Diamond that carries a crystal page is carrying a PROGRAM |
| 21 | written by another person, and a receiver must accept it |
| 22 | before it is written, let alone run. The claim that there |
| 23 | is such a program is `code`, and it is INSIDE THE SIGNED |
| 24 | PAYLOAD — not in a wrapper this file or a relay adds. A |
| 25 | flag either of those could set or clear is not a consent |
| 26 | flag; what makes this one worth showing a person is that |
| 27 | it cannot be touched without the signature failing. |
| 28 | |
| 29 | The gate is on the WRITE, not on the mount. Once a page is |
| 30 | inside a Diamond, opening that Diamond mounts it, so a |
| 31 | question asked at mount time is a question asked too late. |
| 32 | |
| 33 | ── ONE SEAL, NOT TWO ─────────────────────────────────────── |
| 34 | The sealing here is `DaimondPost.seal` / `.unseal`, called and |
| 35 | not copied: the same DPS1 head, the same X25519 + HKDF slot |
| 36 | per recipient, the same AES-GCM body bound to the whole head. |
| 37 | A second way of encrypting is how one of the two stops being |
| 38 | reviewed — voice.js says it about secrets at rest and it is |
| 39 | just as true in flight. |
| 40 | |
| 41 | ── AND IT HAS TO GET THERE ───────────────────────────────── |
| 42 | A share is composed to bytes and then somebody has to CARRY |
| 43 | them. There are two carriers and the choice is made by |
| 44 | measuring, not by asking: `/api/post` refuses a sealed envelope |
| 45 | over 64 KiB, and the Log Life capp page alone is about 64 KB, so |
| 46 | a share carrying a capp cannot go through the relay at all. |
| 47 | |
| 48 | So a share too large is written out as a `.dshare` file, and one |
| 49 | arriving as a file is read back in — `carrier`, `save`, `take` |
| 50 | and `pick` below. This is not a fallback for the awkward case: |
| 51 | it is the only route a capp has, and it is the honest route for |
| 52 | a person with no gateway account or a share going onto a stick. |
| 53 | |
| 54 | A `.dshare` IS NOT MORE TRUSTED THAN A MESSAGE. The bytes are |
| 55 | the same sealed envelope, and `take` goes through the same |
| 56 | `receive` → `accept` → `askAboutCode` path. Opening a file |
| 57 | somebody handed you must never become a way of running their |
| 58 | program. |
| 59 | |
| 60 | ── WHAT NEVER TRAVELS ────────────────────────────────────── |
| 61 | `.daimond/`, `versions/` and `capp.json` are refused by the |
| 62 | FORMAT (fe2o3_sbj `share.rs`), not by this file, so every |
| 63 | implementation refuses them: the sender's agent log and their |
| 64 | own history are not part of a recipe, and a delivery record |
| 65 | carried across from somebody else's machine would pin the |
| 66 | receiver's copy against updates they never chose. |
| 67 | |
| 68 | Attaches one global, `window.DaimondShare`. |
| 69 | ============================================================ */ |
| 70 | (function () { |
| 71 | 'use strict'; |
| 72 | |
| 73 | // ── Saying things ────────────────────────────────────────── |
| 74 | |
| 75 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 76 | |
| 77 | /// A string from the table, or the English written at the call site where the |
| 78 | /// table has no entry for it yet. The same device post.js and voice.js use. |
| 79 | function tOr(k, fallback, v) { |
| 80 | var s = t(k, v); |
| 81 | if (s !== k) return s; |
| 82 | if (!v) return fallback; |
| 83 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) { |
| 84 | return v[name] != null ? String(v[name]) : whole; |
| 85 | }); |
| 86 | } |
| 87 | |
| 88 | function log(/* ...args */) { |
| 89 | try { |
| 90 | if (!window.DAIMOND_DEBUG) return; |
| 91 | console.log.apply(console, ['[share]'].concat([].slice.call(arguments))); |
| 92 | } catch (e) { /* no console */ } |
| 93 | } |
| 94 | |
| 95 | // ── What a share is ──────────────────────────────────────── |
| 96 | |
| 97 | /// The schema every share is signed under. The purpose tag is inside the |
| 98 | /// signing input, so a signature over a share can never be read as one over a |
| 99 | /// message, and the other way about. |
| 100 | var SCHEMA = 'daimond/share/0'; |
| 101 | |
| 102 | /// What a sealed share is called when it is handed over as a file. |
| 103 | var EXT = '.dshare'; |
| 104 | |
| 105 | /// The type a `.dshare` travels under. It is CIPHERTEXT, so there is nothing |
| 106 | /// truthful to say about its contents and nothing a browser should try to do |
| 107 | /// with it but save it. |
| 108 | var MIME = 'application/octet-stream'; |
| 109 | |
| 110 | /// The largest sealed envelope `/api/post` carries: the gateway's own |
| 111 | /// `max_bytes` on that route (`gateway/src/settings.rs`, fallback 65536). |
| 112 | var RELAY_MAX = 64 * 1024; |
| 113 | |
| 114 | /// The most a share may carry, in bytes of file bodies. Exactly |
| 115 | /// `limit::TOTAL_BYTES` in the schema's own crate: checked here so a person is |
| 116 | /// told before they have waited for anything, and checked there because that |
| 117 | /// is the authority. |
| 118 | var TOTAL_MAX = 2 * 1024 * 1024; |
| 119 | |
| 120 | /// The most files one share may carry. `limit::FILES`, for the same reason. |
| 121 | var FILES_MAX = 64; |
| 122 | |
| 123 | /// The most the covering note may carry, in bytes of UTF-8. `limit::NOTE_BYTES`. |
| 124 | var NOTE_MAX = 512; |
| 125 | |
| 126 | /// The largest `.dshare` this will even try to open. `TOTAL_MAX` is the ceiling |
| 127 | /// on the file bodies; the head, one slot per recipient, the paths, the note |
| 128 | /// and the seal's own tag sit on top of it, so this is that ceiling with room |
| 129 | /// for the wrapping. Checked before anything is unsealed, so a file somebody |
| 130 | /// dropped in by mistake costs a sentence rather than a megabyte of work. |
| 131 | var FILE_MAX = TOTAL_MAX + 64 * 1024; |
| 132 | |
| 133 | /// What a share may not carry, applied here so the sender is not handed a |
| 134 | /// refusal from the encoder for a file they never asked to send. |
| 135 | /// |
| 136 | /// THE FORMAT IS THE AUTHORITY -- fe2o3_sbj `share.rs` refuses these three |
| 137 | /// whatever this file does -- and `collect` below DROPS such a file SILENTLY |
| 138 | /// rather than failing or reporting it. That is deliberate and it is the one |
| 139 | /// place in this file where something goes missing without the sender being |
| 140 | /// told: a person sharing a recipe did not ask for their agent log to go with |
| 141 | /// it and should not have to know it exists to get the recipe sent. |
| 142 | /// |
| 143 | /// The sentence here used to say the sender WAS told which of their files was |
| 144 | /// left out. They were not, and never had been -- `collect` writes a debug |
| 145 | /// line and returns only what travels. The behaviour is right; the sentence |
| 146 | /// was describing a courtesy the code does not perform, which is how a reader |
| 147 | /// concludes a gap is covered. Corrected rather than implemented. |
| 148 | /// |
| 149 | /// The rule three lines below is the one that DOES hold, and it is the |
| 150 | /// principle worth keeping in view: a share too large is refused rather than |
| 151 | /// trimmed, because a copy missing a file is not a smaller copy. These three |
| 152 | /// are not part of the copy at all, which is why they are the exception. |
| 153 | var NEVER_TRAVELS = /^(\.daimond\/|versions\/|capp\.json$)/; |
| 154 | |
| 155 | // ── Bytes ────────────────────────────────────────────────── |
| 156 | |
| 157 | function utf8(s) { return new TextEncoder().encode(String(s)); } |
| 158 | |
| 159 | function fromUtf8(b) { return new TextDecoder().decode(b); } |
| 160 | |
| 161 | function b64enc(buf) { |
| 162 | var b = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf); |
| 163 | var s = ''; |
| 164 | for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); |
| 165 | return btoa(s); |
| 166 | } |
| 167 | |
| 168 | function b64dec(str) { |
| 169 | var s = atob(String(str)); |
| 170 | var b = new Uint8Array(s.length); |
| 171 | for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); |
| 172 | return b; |
| 173 | } |
| 174 | |
| 175 | /// base64url, as the app writes a key. |
| 176 | function urldec(s) { |
| 177 | var b = String(s).replace(/-/g, '+').replace(/_/g, '/'); |
| 178 | while (b.length % 4) b += '='; |
| 179 | return b64dec(b); |
| 180 | } |
| 181 | |
| 182 | /// Thirty-two key bytes, from whatever a caller is holding. |
| 183 | /// |
| 184 | /// A DEFECT ONLY A REAL CALLER COULD FIND. `compose` decoded `toEnc` with |
| 185 | /// `b64dec` and `to` with `urldec`, and the app's own people directory carries |
| 186 | /// BOTH as 64 characters of hex -- `readCard` in trust.js takes them straight |
| 187 | /// out of the crate's JSON, where `key` is documented as hex and `enc` is the |
| 188 | /// same. So a share composed to somebody the user actually knows was refused |
| 189 | /// with "there is no sealing key for that person yet", which is the one wrong |
| 190 | /// thing that sentence could have said: the key was right there and this file |
| 191 | /// was reading it in the wrong alphabet. |
| 192 | /// |
| 193 | /// Nothing caught it because nothing called `compose` with a directory record |
| 194 | /// until the Share view existed. A parameter contract no caller exercises is a |
| 195 | /// parameter contract nobody has checked. |
| 196 | /// |
| 197 | /// Hex is tried FIRST and only on an exact 64 characters of hex digits, because |
| 198 | /// a 64-character base64url string is also a legal 48-byte key encoding and |
| 199 | /// guessing wrong there would swap a real refusal for a silent wrong key. |
| 200 | function keyBytes(v) { |
| 201 | if (v instanceof Uint8Array) return v; |
| 202 | if (!v) return null; |
| 203 | var str = String(v); |
| 204 | if (/^[0-9a-fA-F]{64}$/.test(str)) return unhex(str); |
| 205 | try { return urldec(str); } catch (e) { return null; } |
| 206 | } |
| 207 | |
| 208 | /// The bytes of a hex string. `hex` below is the other direction. |
| 209 | function unhex(str) { |
| 210 | var s = String(str), out = new Uint8Array(s.length >> 1); |
| 211 | for (var i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16); |
| 212 | return out; |
| 213 | } |
| 214 | |
| 215 | function hex(bytes) { |
| 216 | var b = (bytes instanceof Uint8Array) ? bytes : new Uint8Array(bytes); |
| 217 | var s = ''; |
| 218 | for (var i = 0; i < b.length; i++) s += ('0' + b[i].toString(16)).slice(-2); |
| 219 | return s; |
| 220 | } |
| 221 | |
| 222 | function sameBytes(a, b) { |
| 223 | if (!a || !b || a.length !== b.length) return false; |
| 224 | for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } |
| 225 | return true; |
| 226 | } |
| 227 | |
| 228 | // ── The bridges ──────────────────────────────────────────── |
| 229 | // |
| 230 | // The same arrangement post.js and identity.js use, and for the same reason: |
| 231 | // this is a classic script, the canonical encoding lives in the format's own |
| 232 | // crate, and a second encoding written in JavaScript would be a second address |
| 233 | // for one share. Nothing here computes what the crate owns. |
| 234 | |
| 235 | function bridge() { |
| 236 | return (typeof window !== 'undefined' && window.DaimondCrypto) || null; |
| 237 | } |
| 238 | |
| 239 | /// Whether the wasm bridge carries everything this file needs. |
| 240 | /// |
| 241 | /// `shareDraft` and `shareRead` are the two names post.js did not need. Said |
| 242 | /// out loud when they are missing rather than worked around: a share encoded |
| 243 | /// here instead would have a different address from the same share encoded by |
| 244 | /// any other build, and a share verified here instead would be a second |
| 245 | /// implementation of the one check that matters. |
| 246 | function cryptoReady() { |
| 247 | var b = bridge(); |
| 248 | return !!(b && typeof b.shareDraft === 'function' && typeof b.shareRead === 'function' |
| 249 | && typeof b.signingInput === 'function' && typeof b.assemble === 'function' |
| 250 | && typeof b.address === 'function'); |
| 251 | } |
| 252 | |
| 253 | /// Whether the seal is reachable. It is post.js's, called and not copied. |
| 254 | function sealReady() { |
| 255 | return !!(window.DaimondPost && typeof DaimondPost.seal === 'function' |
| 256 | && typeof DaimondPost.unseal === 'function'); |
| 257 | } |
| 258 | |
| 259 | /// Whether this build can put a share ON somebody's machine. |
| 260 | /// |
| 261 | /// Separate from `cryptoReady` because the two fail differently and a person |
| 262 | /// deserves to know which: without the format nothing can be composed at all, |
| 263 | /// and without this a share can be composed, sent, opened and read and there |
| 264 | /// is nowhere for it to land. |
| 265 | function landReady() { |
| 266 | return !!(window.DaimondDiamond && typeof DaimondDiamond.land === 'function' |
| 267 | && typeof DaimondDiamond.files === 'function'); |
| 268 | } |
| 269 | |
| 270 | /// Why sharing cannot be used, in words, or '' when it can. |
| 271 | function why() { |
| 272 | if (!bridge() || !cryptoReady()) return tOr('share.err_no_bridge', |
| 273 | 'This build cannot share a Diamond: its share format is not loaded.'); |
| 274 | if (!sealReady()) return tOr('share.err_no_seal', |
| 275 | 'This build cannot share a Diamond: the seal it would be sent under is not loaded.'); |
| 276 | if (!landReady()) return tOr('share.err_no_store', |
| 277 | 'This build can read a share but has nowhere to put one.'); |
| 278 | return ''; |
| 279 | } |
| 280 | |
| 281 | function ready() { return !why(); } |
| 282 | |
| 283 | // ── Collecting what travels ──────────────────────────────── |
| 284 | |
| 285 | /// Everything of a Diamond that a share carries, as `[{path, body}]`. |
| 286 | /// |
| 287 | /// The three things that never travel are dropped HERE, silently, rather than |
| 288 | /// refused: a person sharing a recipe did not ask for their agent log to go |
| 289 | /// with it and should not have to know it exists to get the recipe sent. The |
| 290 | /// format refuses them too, which is what makes this a convenience rather than |
| 291 | /// the guard. |
| 292 | async function collect(id) { |
| 293 | if (!landReady()) { |
| 294 | throw new Error(tOr('share.err_no_store', |
| 295 | 'This build can read a share but has nowhere to put one.')); |
| 296 | } |
| 297 | var all = await DaimondDiamond.files(String(id)); |
| 298 | var out = []; |
| 299 | var total = 0; |
| 300 | for (var i = 0; i < (all || []).length; i++) { |
| 301 | var f = all[i]; |
| 302 | var path = String((f && f.path) || ''); |
| 303 | if (!path || NEVER_TRAVELS.test(path)) { log('not travelling', path); continue; } |
| 304 | var body = f.body instanceof Uint8Array ? f.body |
| 305 | : (typeof f.body === 'string' ? utf8(f.body) : new Uint8Array(f.body || [])); |
| 306 | total += body.length; |
| 307 | out.push({ path: path, body: body }); |
| 308 | } |
| 309 | if (!out.length) { |
| 310 | throw new Error(tOr('share.err_empty', |
| 311 | 'There is nothing in that Diamond to send yet.')); |
| 312 | } |
| 313 | if (out.length > FILES_MAX) { |
| 314 | throw new Error(tOr('share.err_too_many_files', |
| 315 | 'That Diamond holds {n} files, and a share carries at most {max}.', |
| 316 | { n: out.length, max: FILES_MAX })); |
| 317 | } |
| 318 | if (total > TOTAL_MAX) { |
| 319 | throw new Error(tOr('share.err_too_big', |
| 320 | 'That Diamond is {mb} MB, and a share carries at most {max} MB. It is refused ' |
| 321 | + 'rather than trimmed: a copy missing a file is not a smaller copy.', |
| 322 | { mb: (total / (1024 * 1024)).toFixed(1), |
| 323 | max: (TOTAL_MAX / (1024 * 1024)).toFixed(0) })); |
| 324 | } |
| 325 | return out; |
| 326 | } |
| 327 | |
| 328 | // ── Composing ────────────────────────────────────────────── |
| 329 | |
| 330 | /// Build, sign and seal one share. Answers |
| 331 | /// `{ addr, artefact, sealed, envelope, code, ts }`. |
| 332 | /// |
| 333 | /// THE SEAM, exactly as post.js draws it: the crate encodes the payload and |
| 334 | /// says what to sign, this signs it with a key that never crosses the |
| 335 | /// boundary, and the crate takes the signature back and assembles. The |
| 336 | /// envelope is a pure function of the same four arguments both calls are |
| 337 | /// given, so a caller cannot sign one envelope and assemble another. |
| 338 | /// |
| 339 | /// `to` is the recipient's SIGNING key, and it goes inside the payload, so a |
| 340 | /// share lifted out of one sealed envelope and dropped into another is caught |
| 341 | /// when it is opened. `toEnc` is their SEALING key, which is a different key |
| 342 | /// for a stated reason (see identity.js), and is what the slot is made for. |
| 343 | /// |
| 344 | /// `code` comes back so a caller can say what they are about to send BEFORE |
| 345 | /// they send it. It is the crate's own answer, asked of the draft, so the |
| 346 | /// sentence a sender reads and the claim their signature carries cannot |
| 347 | /// disagree. |
| 348 | async function compose(opts) { |
| 349 | var o = opts || {}; |
| 350 | var stop = why(); |
| 351 | // A build with nowhere to LAND a share can still compose one, so the store |
| 352 | // is not required here; the other two are. |
| 353 | if (!cryptoReady() || !sealReady()) { |
| 354 | throw new Error(stop || tOr('share.err_no_bridge', |
| 355 | 'This build cannot share a Diamond: its share format is not loaded.')); |
| 356 | } |
| 357 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) { |
| 358 | throw new Error(tOr('share.err_locked', |
| 359 | 'Unlock Daimond to share: a share is signed with your own key.')); |
| 360 | } |
| 361 | |
| 362 | var name = String(o.name == null ? '' : o.name).trim(); |
| 363 | if (!name) { |
| 364 | throw new Error(tOr('share.err_no_name', 'A share needs a name for what is in it.')); |
| 365 | } |
| 366 | var note = String(o.note == null ? '' : o.note).trim(); |
| 367 | if (utf8(note).length > NOTE_MAX) { |
| 368 | throw new Error(tOr('share.err_note_long', |
| 369 | 'That note is longer than {n} characters and was not sent. A share carries a line ' |
| 370 | + 'about what it is; a letter is a message.', { n: NOTE_MAX })); |
| 371 | } |
| 372 | |
| 373 | var toPub = keyBytes(o.to); |
| 374 | if (!toPub || toPub.length !== 32) { |
| 375 | throw new Error(tOr('share.err_bad_key', |
| 376 | 'That person has no usable key, so nothing was sent.')); |
| 377 | } |
| 378 | var toEnc = o.toEnc ? keyBytes(o.toEnc) : encFor(o.to); |
| 379 | if (!toEnc || toEnc.length !== 32) { |
| 380 | throw new Error(tOr('share.err_no_card', |
| 381 | 'There is no sealing key for that person yet, so nothing can be sealed to them. ' |
| 382 | + 'Scan their code, or ask them to send you theirs.')); |
| 383 | } |
| 384 | |
| 385 | // The files: named by the caller, or collected from the Diamond they named. |
| 386 | var files = o.files; |
| 387 | if (!files) { |
| 388 | if (!o.diamond) { |
| 389 | throw new Error(tOr('share.err_nothing', |
| 390 | 'There is nothing to share: name a Diamond or the files to send.')); |
| 391 | } |
| 392 | files = await collect(o.diamond); |
| 393 | } |
| 394 | |
| 395 | var b = bridge(); |
| 396 | var nonce = crypto.getRandomValues(new Uint8Array(16)); |
| 397 | var draft = b.shareDraft(name, toPub, nonce); |
| 398 | var payload, code; |
| 399 | try { |
| 400 | if (note) draft.note(note); |
| 401 | for (var i = 0; i < files.length; i++) { |
| 402 | var f = files[i]; |
| 403 | var body = f.body instanceof Uint8Array ? f.body |
| 404 | : (typeof f.body === 'string' ? utf8(f.body) : new Uint8Array(f.body || [])); |
| 405 | draft.addFile(String(f.path), body); |
| 406 | } |
| 407 | // Asked BEFORE encoding, so a caller can still stop; the crate computes |
| 408 | // it and the payload's own `code` bit is computed from the same rule. |
| 409 | code = !!draft.carriesCode(); |
| 410 | payload = draft.encode(); |
| 411 | } finally { |
| 412 | // A wasm-bindgen object holds memory on the other side of the boundary |
| 413 | // until it is told to let go, and a draft that is not freed is a leak per |
| 414 | // share rather than per session. |
| 415 | try { if (draft && draft.free) draft.free(); } catch (e) { /* already freed */ } |
| 416 | } |
| 417 | |
| 418 | var author = await DaimondIdentity.publicKeyRaw(); |
| 419 | var when = Date.now(); |
| 420 | var input = b.signingInput(payload, SCHEMA, author, when); |
| 421 | // `sign` answers STANDARD base64, not base64url. The envelope wants the raw |
| 422 | // bytes, so it is decoded rather than passed on as text. |
| 423 | var sig = b64dec(await DaimondIdentity.sign(input)); |
| 424 | var artefact = b.assemble(payload, SCHEMA, author, when, sig); |
| 425 | var addr = hex(b.address(payload)); |
| 426 | |
| 427 | // The sender's own slot, so their other devices can re-read what they sent. |
| 428 | // Left out when this device has no sealing key: better a share the sender |
| 429 | // cannot re-open than one that cannot be sent at all. |
| 430 | var mine = DaimondIdentity.sealingKeyRaw(); |
| 431 | var to = [toEnc]; |
| 432 | if (mine && !sameBytes(mine, toEnc)) to.push(mine); |
| 433 | |
| 434 | var sealed = await DaimondPost.seal(to, artefact); |
| 435 | return { |
| 436 | addr: addr, |
| 437 | // THE NAME COMES BACK, and its absence was a defect rather than a gap: |
| 438 | // `filename` below builds the file's stem from `made.name`, so without |
| 439 | // this every `.dshare` anybody ever saved would have been called |
| 440 | // `share-<addr>.dshare` and the name the sender chose would have reached |
| 441 | // nobody. It is the sender's own text, cleaned there and not here. |
| 442 | name: name, |
| 443 | artefact: artefact, |
| 444 | sealed: sealed, |
| 445 | envelope: b64enc(sealed), |
| 446 | code: code, |
| 447 | ts: when, |
| 448 | }; |
| 449 | } |
| 450 | |
| 451 | /// A recipient's sealing key, from trust.js's projection where there is one. |
| 452 | /// |
| 453 | /// trust.js is the only authority on who is who; nothing here holds a |
| 454 | /// directory of its own. post.js reads the same projection, and this asks it |
| 455 | /// the same way rather than keeping a second copy of the answer. |
| 456 | function encFor(pub) { |
| 457 | try { |
| 458 | if (window.DaimondPost && DaimondPost.people) { |
| 459 | var list = DaimondPost.people() || []; |
| 460 | for (var i = 0; i < list.length; i++) { |
| 461 | if (String(list[i].pub) === String(pub) && list[i].enc) { |
| 462 | return keyBytes(list[i].enc); |
| 463 | } |
| 464 | } |
| 465 | } |
| 466 | } catch (e) { /* no directory: the caller must name the key */ } |
| 467 | return null; |
| 468 | } |
| 469 | |
| 470 | // ── Opening ──────────────────────────────────────────────── |
| 471 | |
| 472 | /// Open one sealed share and say what it turned out to be. |
| 473 | /// |
| 474 | /// THE READER CHECKS AND NOBODY ELSE. Magic, envelope, address, signature, |
| 475 | /// canonical encoding and schema all run in `DaimondCrypto.shareRead`, on this |
| 476 | /// device. Two checks are made here on top of it, and both are about this |
| 477 | /// account rather than about the artefact: |
| 478 | /// |
| 479 | /// - the payload's `to` must be THIS account's key. A share sealed to us but |
| 480 | /// addressed to somebody else is a share somebody re-slotted, and the |
| 481 | /// signature covers `to`, so this catches it; |
| 482 | /// - where the caller was told an address, it must be the address the |
| 483 | /// artefact has, or the two are not the same thing. |
| 484 | /// |
| 485 | /// Nothing is written by this function. What comes back is a reading, and |
| 486 | /// `accept` is the only thing that puts anything on the machine. |
| 487 | async function openSealed(bytes, expectAddr) { |
| 488 | if (!cryptoReady() || !sealReady()) { |
| 489 | throw new Error(tOr('share.err_no_bridge', |
| 490 | 'This build cannot share a Diamond: its share format is not loaded.')); |
| 491 | } |
| 492 | var b = (bytes instanceof Uint8Array) ? bytes |
| 493 | : (typeof bytes === 'string' ? b64dec(bytes) : new Uint8Array(bytes)); |
| 494 | var plain = await DaimondPost.unseal(b); |
| 495 | var read = bridge().shareRead(plain); // throws, with the reason, on anything wrong |
| 496 | |
| 497 | try { |
| 498 | var mine = await DaimondIdentity.publicKeyRaw(); |
| 499 | if (!mine || !sameBytes(mine, read.to())) { |
| 500 | throw new Error(tOr('share.err_not_addressed', |
| 501 | 'That share is addressed to a different key from this one.')); |
| 502 | } |
| 503 | if (expectAddr && String(expectAddr) !== read.address()) { |
| 504 | throw new Error(tOr('share.err_addr_mismatch', |
| 505 | 'The share you were told about is not the share that arrived.')); |
| 506 | } |
| 507 | } catch (e) { |
| 508 | try { if (read && read.free) read.free(); } catch (e2) { /* already freed */ } |
| 509 | throw e; |
| 510 | } |
| 511 | return read; |
| 512 | } |
| 513 | |
| 514 | /// Everything a reading says, as plain values, so a caller can draw it without |
| 515 | /// holding the wasm object open. |
| 516 | /// |
| 517 | /// The wasm side owns the file BODIES and they are not copied out here: a |
| 518 | /// share may be two megabytes and a summary is a sentence. `accept` takes the |
| 519 | /// bodies, one at a time, at the moment it writes them. |
| 520 | /// |
| 521 | /// **`code` is `read.code()` and must stay so.** It is the sender's signed |
| 522 | /// claim; the per-file `code` beside each path is THIS build's reading of the |
| 523 | /// suffix, which is a different fact wearing the same word. Today the two |
| 524 | /// always agree, because the schema refuses a payload where they do not — so |
| 525 | /// no test here can tell a build that reads the claim from one that recomputes |
| 526 | /// it, and swapping them would go unnoticed until a later build learned a |
| 527 | /// suffix this one does not know. That is exactly the case the signed bit |
| 528 | /// exists for, and it is the case a green test would be silent about. |
| 529 | function describe(read) { |
| 530 | var files = []; |
| 531 | var n = read.count(); |
| 532 | for (var i = 0; i < n; i++) { |
| 533 | files.push({ path: read.path(i), code: !!read.isCode(i) }); |
| 534 | } |
| 535 | return { |
| 536 | name: read.name(), |
| 537 | note: read.note(), |
| 538 | code: !!read.code(), |
| 539 | author: read.author(), |
| 540 | address: read.address(), |
| 541 | ts: read.time(), |
| 542 | files: files, |
| 543 | }; |
| 544 | } |
| 545 | |
| 546 | /// The files of a reading that are code, by path. |
| 547 | function codePaths(read) { |
| 548 | var out = []; |
| 549 | for (var i = 0; i < read.count(); i++) { |
| 550 | if (read.isCode(i)) out.push(read.path(i)); |
| 551 | } |
| 552 | return out; |
| 553 | } |
| 554 | |
| 555 | // ── Consent ──────────────────────────────────────────────── |
| 556 | |
| 557 | /// Ask, in the app's own words, whether a page written by somebody else may be |
| 558 | /// written into this workspace. |
| 559 | /// |
| 560 | /// **In app chrome, never in a frame.** A click inside a rendered page is a |
| 561 | /// click whose user activation the app cannot verify, so a timer is |
| 562 | /// indistinguishable from a person; this is asked where a click is provably |
| 563 | /// somebody's, which is the same rule `makeCappDiamond` follows. |
| 564 | /// |
| 565 | /// It says three things, and each is there because leaving it out would make |
| 566 | /// the question unanswerable: WHAT it is (a page — a program), WHOSE it is |
| 567 | /// (the sender's fingerprint, since a display name is advisory and a key is |
| 568 | /// not), and WHICH files (named, so "it contains code somewhere" is never the |
| 569 | /// whole of what anybody is told). |
| 570 | async function askAboutCode(read) { |
| 571 | var paths = codePaths(read); |
| 572 | var who = fingerprintOf(read.author()); |
| 573 | var body = tOr('share.code_body', |
| 574 | '“{name}” includes a page: a program written by somebody else, which Daimond will ' |
| 575 | + 'run when you open it. It came from {who}. Accept it only if you meant to receive ' |
| 576 | + 'a page from them.\n\nWhat would be added: {files}', |
| 577 | { name: read.name(), who: who, files: paths.join(', ') }); |
| 578 | if (window.DaimondCore && typeof DaimondCore.confirm === 'function') { |
| 579 | return await DaimondCore.confirm(body, tOr('share.code_ok', 'Accept the page'), |
| 580 | { title: tOr('share.code_title', 'This share contains code'), danger: true }); |
| 581 | } |
| 582 | // No dialog is not a reason to write it anyway. A build with nothing to ask |
| 583 | // with refuses, which is the only answer that is honest here: the fence is |
| 584 | // the consent, not the dialog. |
| 585 | log('no confirm dialog: refusing the code'); |
| 586 | return false; |
| 587 | } |
| 588 | |
| 589 | /// The sender's key as a person reads it. It DECIDES nothing, and is shown |
| 590 | /// because a display name is the sender's own text and a key is not. |
| 591 | function fingerprintOf(key) { |
| 592 | try { |
| 593 | var b = bridge(); |
| 594 | if (b && typeof b.fingerprint === 'function') return b.fingerprint(key); |
| 595 | } catch (e) { /* fall through to the raw form */ } |
| 596 | return hex(key).slice(0, 16); |
| 597 | } |
| 598 | |
| 599 | // ── Landing ──────────────────────────────────────────────── |
| 600 | |
| 601 | /// Put a share on this machine as a Diamond of the receiver's own. |
| 602 | /// |
| 603 | /// `opts.withCode` decides whether the code files are written: `undefined` |
| 604 | /// asks (`askAboutCode`), `true` writes them, `false` writes only the data. A |
| 605 | /// caller drawing two buttons passes the answer; a caller drawing one asks. |
| 606 | /// |
| 607 | /// **All or nothing within the answer given.** A share that half-landed would |
| 608 | /// be a Diamond nobody chose the contents of, so a failure to write leaves the |
| 609 | /// error to the caller rather than reporting a partial success. |
| 610 | /// |
| 611 | /// Answers `{ ok, id, wrote, left, said }` — what went in, what was |
| 612 | /// deliberately left out, and THE SENTENCE FOR IT. `said` is `''` when |
| 613 | /// everything landed, and it is not optional decoration: a result carrying |
| 614 | /// `ok: true` beside a count of what it failed to do is how a user comes to be |
| 615 | /// told success over a partial one, and a count with no words attached is a |
| 616 | /// count every caller has to remember to look at. The total case -- nothing |
| 617 | /// landed at all, because the whole share was a page -- comes back `ok: false` |
| 618 | /// with its own sentence in `why`. |
| 619 | async function accept(read, opts) { |
| 620 | var o = opts || {}; |
| 621 | if (!landReady()) { |
| 622 | throw new Error(tOr('share.err_no_store', |
| 623 | 'This build can read a share but has nowhere to put one.')); |
| 624 | } |
| 625 | var withCode = o.withCode; |
| 626 | if (read.code() && withCode === undefined) withCode = await askAboutCode(read); |
| 627 | if (!read.code()) withCode = false; // nothing to include, so nothing was accepted |
| 628 | |
| 629 | var files = [], left = []; |
| 630 | for (var i = 0; i < read.count(); i++) { |
| 631 | var path = read.path(i); |
| 632 | if (read.isCode(i) && !withCode) { left.push(path); continue; } |
| 633 | files.push({ path: path, body: read.body(i) }); |
| 634 | } |
| 635 | if (!files.length) { |
| 636 | return { ok: false, why: tOr('share.err_all_code', |
| 637 | 'Everything in that share is a page, and the page was not accepted, so nothing ' |
| 638 | + 'has been added.'), left: left, skipped: [], said: '' }; |
| 639 | } |
| 640 | |
| 641 | // The receiver's OWN Diamond: a new one, with a new identity, carrying no |
| 642 | // record of the sender's delivery and no history of theirs. The name is |
| 643 | // advisory — the store settles a clash, since two people may pick one name |
| 644 | // and neither is wrong. |
| 645 | var id = await DaimondDiamond.land(read.name(), files); |
| 646 | log('landed', id, files.length, 'files,', left.length, 'left out'); |
| 647 | // A PARTIAL LANDING SAYS SO, and `ok: true` beside a count of what did not |
| 648 | // arrive is exactly how it stopped saying so. `left` was returned and |
| 649 | // nothing anywhere read it: a share of five files of which two were pages |
| 650 | // landed three, reported success, and never mentioned the other two -- and |
| 651 | // the receiver cannot go and look for what they were never told about. |
| 652 | // `share.err_all_code` covered only the total case, where nothing lands at |
| 653 | // all, which is the case a person cannot fail to notice. |
| 654 | // |
| 655 | // The sentence goes back with the result as well as onto the screen, so a |
| 656 | // caller cannot end up holding the list without the words for it. That is |
| 657 | // the half that stays true in a build with no dialog on the page. |
| 658 | // THE SHAPE `post.js` ALREADY REPORTS, not a third one. `shortfall(r)` takes |
| 659 | // a whole answer and names everything in it that fell short, and group.js |
| 660 | // reports through the same function; a bespoke sentence here would be the |
| 661 | // third wording for one fact and the next field somebody adds to this answer |
| 662 | // would be reported in two places or none. A landing leaves out FILES rather |
| 663 | // than recipients, so the paths are given as `skipped` entries -- a label and |
| 664 | // the reason -- which is the half of that shape they fit. |
| 665 | var skipped = left.map(function (path) { |
| 666 | return { label: path, why: tOr('share.left_page', |
| 667 | 'it is a page you did not accept') }; |
| 668 | }); |
| 669 | var out = { ok: true, id: id, wrote: files.map(function (f) { return f.path; }), |
| 670 | left: left, skipped: skipped, said: '' }; |
| 671 | out.said = shortSaid(out); |
| 672 | if (out.said) tell(out.said); |
| 673 | return out; |
| 674 | } |
| 675 | |
| 676 | /// What did not land, as a sentence, or '' when everything did. |
| 677 | /// |
| 678 | /// `DaimondPost.shortfall` IS THE AUTHORITY AND THERE IS NO SECOND COPY OF IT. |
| 679 | /// This carried one: the old `skipWords` body, joining the labels itself and |
| 680 | /// saying them through `post.group_skipped` -- a second wording for "these |
| 681 | /// people have not got it", live on the receiving path, and the fifth and last |
| 682 | /// instance of the class the other four were fixed for. Two wordings for one |
| 683 | /// fact is how one of them stops being translated, and the one that stops is |
| 684 | /// always the one nobody is looking at. |
| 685 | /// |
| 686 | /// `shortfall` takes THE WHOLE ANSWER rather than the `skipped` field, which is |
| 687 | /// the reason it is reachable from here at all: the next thing added to a |
| 688 | /// landing's answer is reported by it or nowhere, and this file does not have to |
| 689 | /// learn what that thing was. |
| 690 | /// |
| 691 | /// WHEN THE AUTHORITY IS ABSENT THERE IS NO SENTENCE, and the absence is said |
| 692 | /// out loud rather than papered over. `www/index.html` loads `js/post.js` ten |
| 693 | /// lines above this file, so a build reaching that branch is a build with a |
| 694 | /// missing script, which is a fault to see rather than to translate around. |
| 695 | function shortSaid(r) { |
| 696 | if (!r || !r.skipped || !r.skipped.length) return ''; |
| 697 | try { |
| 698 | if (window.DaimondPost && typeof DaimondPost.shortfall === 'function') { |
| 699 | return String(DaimondPost.shortfall(r) || '').trim(); |
| 700 | } |
| 701 | } catch (e) { log('the shortfall sentence would not compose', e); } |
| 702 | log('js/post.js is not loaded, so nothing can say what did not land:', |
| 703 | r.skipped.length, 'file(s)'); |
| 704 | return ''; |
| 705 | } |
| 706 | |
| 707 | /// Put `text` in front of the receiver. |
| 708 | /// |
| 709 | /// NOT AWAITED, and that is a bug avoided rather than a style. `accept` runs |
| 710 | /// inside `receive`'s `try`, whose `finally` frees the wasm reading; awaiting a |
| 711 | /// dialog there would hold that reading open for as long as the dialog stood, |
| 712 | /// and for any caller that is not a person sitting in front of the screen it |
| 713 | /// would never return at all. `landDiamond` in `daimond.js` carries the same |
| 714 | /// note over the same mistake, made once already. |
| 715 | /// |
| 716 | /// `DaimondCore.confirm` with no second button is a one-button notice -- which |
| 717 | /// is what `noticeDialog` is, and that is private to `daimond.js`. A published |
| 718 | /// `DaimondCore.notice` would be the right door; this is the one that exists. |
| 719 | function tell(text) { |
| 720 | try { |
| 721 | if (window.DaimondCore && typeof DaimondCore.confirm === 'function') { |
| 722 | DaimondCore.confirm(text, tOr('dlg.ok', 'OK'), { |
| 723 | title: tOr('share.landed_title', 'Shared Diamond added'), |
| 724 | cancelLabel: null, |
| 725 | danger: false, |
| 726 | }); |
| 727 | return; |
| 728 | } |
| 729 | } catch (e) { /* fall through: the sentence still went back to the caller */ } |
| 730 | log('nothing to say it with:', text); |
| 731 | } |
| 732 | |
| 733 | /// Open a sealed share and land it, asking about code on the way. |
| 734 | /// |
| 735 | /// The whole receiving side in one call, for the control that takes a file. |
| 736 | /// The wasm reading is freed whatever happens, which a caller doing the two |
| 737 | /// steps itself has to remember and this does not. |
| 738 | async function receive(bytes, expectAddr) { |
| 739 | var read = await openSealed(bytes, expectAddr); |
| 740 | try { |
| 741 | return await accept(read); |
| 742 | } finally { |
| 743 | try { if (read && read.free) read.free(); } catch (e) { /* already freed */ } |
| 744 | } |
| 745 | } |
| 746 | |
| 747 | // ── Handing it over ──────────────────────────────────────── |
| 748 | |
| 749 | /// What a sealed share is called when it is written out as a file. |
| 750 | /// |
| 751 | /// The address is in the name, so two shares of one Diamond do not overwrite |
| 752 | /// each other and a person can see that the file they were given is the file |
| 753 | /// they were told about. |
| 754 | function filename(made) { |
| 755 | var stem = String((made && made.name) || 'share') |
| 756 | .replace(/[^A-Za-z0-9 _-]+/g, '').trim().replace(/\s+/g, '-').slice(0, 40); |
| 757 | var addr = String((made && made.addr) || '').slice(0, 12); |
| 758 | return (stem || 'share') + (addr ? '-' + addr : '') + EXT; |
| 759 | } |
| 760 | |
| 761 | // ── Which carrier ────────────────────────────────────────── |
| 762 | // |
| 763 | // A COMPOSED SHARE HAD NOWHERE TO GO. Everything above builds a sealed |
| 764 | // envelope and stops, and the relay -- the only carrier this app had -- refuses |
| 765 | // one over 64 KiB. The Log Life capp page alone is about 64 KB, so a share |
| 766 | // carrying a capp could not go through the relay AT ALL: the whole capp-sharing |
| 767 | // feature dead-ended at a byte count, and it dead-ended silently. |
| 768 | // |
| 769 | // So the carrier is CHOSEN, by measuring, and the file route below is what the |
| 770 | // large case takes. It is also the honest route for a person with no gateway |
| 771 | // account and for a share going onto a memory stick, which is why it is not |
| 772 | // hidden behind the size. |
| 773 | |
| 774 | /// Whether a sealed envelope of `n` bytes fits through the relay. |
| 775 | function fitsRelay(n) { |
| 776 | // BOTH of the gateway's checks, which are not the same number. `/api/post` |
| 777 | // turns a body away on the cheap base64-length estimate BEFORE it decodes |
| 778 | // anything -- `envelope.len() / 4 * 3 > max_bytes` -- and then again on the |
| 779 | // decoded length. base64 rounds up to a group of three, so the estimate is |
| 780 | // the stricter of the two, and a sealed envelope of exactly 64 KiB is |
| 781 | // refused by it: 65,536 bytes is 87,384 characters, and 87384 / 4 * 3 is |
| 782 | // 65,538. The last size that goes through is 65,535 bytes. |
| 783 | return Math.ceil(Number(n) / 3) * 3 <= RELAY_MAX; |
| 784 | } |
| 785 | |
| 786 | /// Which carrier a composed share must take: `'relay'` or `'file'`. |
| 787 | function carrier(made) { |
| 788 | var n = (made && made.sealed) ? made.sealed.length : 0; |
| 789 | return fitsRelay(n) ? 'relay' : 'file'; |
| 790 | } |
| 791 | |
| 792 | /// Which carrier, and why, in the sender's language. |
| 793 | /// |
| 794 | /// The SIZE is in the sentence rather than a bare "too large", because the |
| 795 | /// sender is the only person who can do anything about it and the number is |
| 796 | /// what tells them whether taking one file out would be enough. |
| 797 | function carrierWhy(made) { |
| 798 | var n = (made && made.sealed) ? made.sealed.length : 0; |
| 799 | if (fitsRelay(n)) { |
| 800 | return tOr('share.by_relay', |
| 801 | 'This share is {size} and goes straight to them through the relay.', |
| 802 | { size: kb(n) }); |
| 803 | } |
| 804 | return tOr('share.by_file', |
| 805 | 'This share is {size} and the relay carries at most {max}, so it travels as a ' |
| 806 | + 'file: save it and give them the file. It is sealed to them either way.', |
| 807 | { size: kb(n), max: kb(RELAY_MAX) }); |
| 808 | } |
| 809 | |
| 810 | /// A byte count the way a sender reads one. |
| 811 | function kb(n) { |
| 812 | var v = Number(n) || 0; |
| 813 | if (v < 1024) return v + ' B'; |
| 814 | if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB'; |
| 815 | return (v / (1024 * 1024)).toFixed(1) + ' MB'; |
| 816 | } |
| 817 | |
| 818 | // ── The file route ───────────────────────────────────────── |
| 819 | // |
| 820 | // The handover is the app's own: one `Blob`, one object URL, a synthetic |
| 821 | // `<a download>`, and the URL revoked straight after. Every other place |
| 822 | // Daimond gives somebody a file does exactly this, and a second way of doing |
| 823 | // it would be a second thing to fix. |
| 824 | // |
| 825 | // WHAT DOES NOT CHANGE BY GOING THROUGH A FILE. The bytes are the same sealed |
| 826 | // envelope the relay would have carried: the same signature over the same |
| 827 | // payload, the same slot per recipient, the same address. A `.dshare` is |
| 828 | // therefore no more trusted than a message -- and in particular the CONSENT |
| 829 | // STEP IS THE SAME ONE. `take` goes through `receive`, which goes through |
| 830 | // `accept`, which asks `askAboutCode` before a page is written. Opening a file |
| 831 | // somebody handed you must never become a way of running their program, and |
| 832 | // the gate is on the WRITE rather than on the mount because a Diamond that |
| 833 | // holds a page mounts it the moment it is opened. |
| 834 | |
| 835 | /// Hand a composed share over as a `.dshare`. Answers the name it was given. |
| 836 | function save(made) { |
| 837 | if (!made || !made.sealed || !made.sealed.length) { |
| 838 | throw new Error(tOr('share.err_nothing', |
| 839 | 'There is nothing to share: name a Diamond or the files to send.')); |
| 840 | } |
| 841 | var name = filename(made); |
| 842 | var a = document.createElement('a'); |
| 843 | a.href = URL.createObjectURL(new Blob([made.sealed], { type: MIME })); |
| 844 | a.download = name; |
| 845 | a.rel = 'noopener'; |
| 846 | a.click(); |
| 847 | URL.revokeObjectURL(a.href); |
| 848 | log('saved', name, made.sealed.length, 'bytes'); |
| 849 | return name; |
| 850 | } |
| 851 | |
| 852 | /// What was saved, as a sentence, for a panel that wants to say it. |
| 853 | function savedSaid(name) { |
| 854 | return tOr('share.saved_as', |
| 855 | 'Saved as {name}. Give them that file: it is sealed to them and to nobody else.', |
| 856 | { name: name }); |
| 857 | } |
| 858 | |
| 859 | /// The bytes of a `.dshare`, whatever shape a caller is holding it in. |
| 860 | async function bytesOf(src) { |
| 861 | if (src instanceof Uint8Array) return src; |
| 862 | if (typeof Blob !== 'undefined' && src instanceof Blob) { |
| 863 | return new Uint8Array(await src.arrayBuffer()); |
| 864 | } |
| 865 | if (typeof ArrayBuffer !== 'undefined' && src instanceof ArrayBuffer) { |
| 866 | return new Uint8Array(src); |
| 867 | } |
| 868 | // A base64 envelope, which is the form the relay carries and the form a |
| 869 | // person pastes. Anything else is not a share and says so. |
| 870 | if (typeof src === 'string' && src) return b64dec(src); |
| 871 | throw new Error(tOr('share.err_no_file', |
| 872 | 'No file was chosen, so nothing was opened.')); |
| 873 | } |
| 874 | |
| 875 | /// Take a `.dshare` and land what is in it. |
| 876 | /// |
| 877 | /// `expectAddr` where the receiver was told an address to expect, which is |
| 878 | /// checked by `openSealed` and not here. |
| 879 | /// |
| 880 | /// The two guards before the seal are about the FILE and not about the share: a |
| 881 | /// file of no bytes and a file far larger than any share can be are both |
| 882 | /// answered without decrypting anything, so a wrong file dropped in costs a |
| 883 | /// sentence. Everything that is actually about the share -- magic, envelope, |
| 884 | /// address, signature, canonical encoding, schema, and who it is addressed to |
| 885 | /// -- is checked where it always was. |
| 886 | async function take(src, expectAddr) { |
| 887 | var b = await bytesOf(src); |
| 888 | if (!b.length) { |
| 889 | throw new Error(tOr('share.err_not_share', |
| 890 | 'That file is not a Daimond share.')); |
| 891 | } |
| 892 | if (b.length > FILE_MAX) { |
| 893 | throw new Error(tOr('share.err_file_huge', |
| 894 | 'That file is {size}, which is larger than any share can be, so it was not ' |
| 895 | + 'opened.', { size: kb(b.length) })); |
| 896 | } |
| 897 | return await receive(b, expectAddr); |
| 898 | } |
| 899 | |
| 900 | /// Ask for a `.dshare` from the machine, and land what is chosen. |
| 901 | /// |
| 902 | /// Must be called from a click: an `<input type="file">` opens nothing without |
| 903 | /// a user gesture, which is the browser's own rule and the right one -- a page |
| 904 | /// that could open a file chooser on a timer could open one over something the |
| 905 | /// person meant to press. |
| 906 | function pick(expectAddr) { |
| 907 | return new Promise(function (resolve, reject) { |
| 908 | if (typeof document === 'undefined' || !document.body) { |
| 909 | reject(new Error(tOr('share.err_no_file', |
| 910 | 'No file was chosen, so nothing was opened.'))); |
| 911 | return; |
| 912 | } |
| 913 | var input = document.createElement('input'); |
| 914 | input.type = 'file'; |
| 915 | // Both, because a browser matches the extension and an operating system |
| 916 | // that has never seen a `.dshare` matches the type. |
| 917 | input.accept = EXT + ',' + MIME; |
| 918 | input.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px'; |
| 919 | var done = false; |
| 920 | function finish(fn, arg) { |
| 921 | if (done) return; |
| 922 | done = true; |
| 923 | try { input.remove(); } catch (e) { /* already gone */ } |
| 924 | fn(arg); |
| 925 | } |
| 926 | input.addEventListener('change', function () { |
| 927 | var f = input.files && input.files[0]; |
| 928 | if (!f) { |
| 929 | finish(reject, new Error(tOr('share.err_no_file', |
| 930 | 'No file was chosen, so nothing was opened.'))); |
| 931 | return; |
| 932 | } |
| 933 | // Resolved with the PROMISE of the landing, so a caller awaiting this |
| 934 | // is awaiting the whole of it -- including the consent question. |
| 935 | take(f, expectAddr).then(function (r) { finish(resolve, r); }, |
| 936 | function (e) { finish(reject, e); }); |
| 937 | }); |
| 938 | // A chooser somebody closed still has to SETTLE -- a promise left pending |
| 939 | // is a button that never comes back. It settles as a rejection carrying |
| 940 | // "no file was chosen", and whether that earns a red line is the caller's |
| 941 | // decision and not this function's: nothing here can draw one. |
| 942 | input.addEventListener('cancel', function () { |
| 943 | finish(reject, new Error(tOr('share.err_no_file', |
| 944 | 'No file was chosen, so nothing was opened.'))); |
| 945 | }); |
| 946 | document.body.appendChild(input); |
| 947 | input.click(); |
| 948 | }); |
| 949 | } |
| 950 | |
| 951 | // ── A template: the shape, and how one is opened ─────────── |
| 952 | // |
| 953 | // A DIFFERENT THING FROM A SHARE, travelling by the same route. A share is |
| 954 | // sealed to one named person's key and carries a Diamond as it stands; a |
| 955 | // template is UNSEALED, carries the SHAPE without the contents, and is opened |
| 956 | // by whoever is handed the file. `DaimondApp.export_template` builds one and |
| 957 | // `import_template` opens it; both are the engine's, and everything here is |
| 958 | // the file route and the question in front of them. |
| 959 | // |
| 960 | // TWO FACTS ABOUT ONE THAT ARE NOT GUESSABLE, and both belong in front of the |
| 961 | // person opening it: |
| 962 | // |
| 963 | // - `triggers.json` is NOT in a template, deliberately. A trigger fires with |
| 964 | // nobody pressing anything, so a template carrying one would start work on |
| 965 | // a stranger's machine because they opened a file. Disarming rather than |
| 966 | // dropping was refused: `on: false` does not actually disarm a trigger -- |
| 967 | // the pause tree is the authority -- so a template that carried one and |
| 968 | // said it was off would be worse than one that carries none. |
| 969 | // - Opening one MINTS A NEW DIAMOND and can never write over an existing |
| 970 | // one. The id inside a template says where it was MADE, which is why |
| 971 | // `import_diamond` refuses a template outright: that door deletes the |
| 972 | // directory the pack names, and the person most likely to open a Log Life |
| 973 | // template is the one whose Log Life it would destroy. |
| 974 | // |
| 975 | // AND THE CONSENT STEP IS THE SAME ONE, for the same reason a `.dshare` is no |
| 976 | // more trusted than a message: a Diamond carrying a crystal page is carrying a |
| 977 | // PROGRAM somebody else wrote. `import_template` writes unconditionally, so |
| 978 | // the question is asked HERE, before the call -- there is no `withCode` on |
| 979 | // that door and no half-landing behind it, so the answer is the whole import. |
| 980 | |
| 981 | /// What a template is called when it is handed over as a file. |
| 982 | var TEMPLATE_EXT = '.dtemplate'; |
| 983 | |
| 984 | /// The type it travels under. A pack IS JSON -- readable, and deliberately so: |
| 985 | /// there is nothing sealed about a template and a person may look inside one |
| 986 | /// before opening it. |
| 987 | var TEMPLATE_MIME = 'application/json'; |
| 988 | |
| 989 | /// The largest template this will even try to read. |
| 990 | /// |
| 991 | /// Not a rule of the format: a guard so that a video dropped into the chooser |
| 992 | /// by mistake costs a sentence rather than a parse of sixteen megabytes. A |
| 993 | /// template carries whole file bodies, and a binary one goes as base64, so the |
| 994 | /// ceiling is well above a share's. |
| 995 | var TEMPLATE_MAX = 16 * 1024 * 1024; |
| 996 | |
| 997 | /// What this build considers code, BY SUFFIX AND NOTHING ELSE. |
| 998 | /// |
| 999 | /// A MIRROR OF `fe2o3_sbj::share::is_code_path`, and it is a mirror because |
| 1000 | /// there is no door to that function from JavaScript: the judgement reaches |
| 1001 | /// this side only as `ShareRead.isCode(i)`, which needs a signed, sealed share |
| 1002 | /// to exist at all, and a template is neither. So the list is repeated here, |
| 1003 | /// ONCE, in the module that owns the idea of code travelling by consent, and |
| 1004 | /// `codePaths` above is what reads it -- rather than a second opinion growing |
| 1005 | /// beside the import button. **A `is_code_path(path)` export on the wasm would |
| 1006 | /// remove this; it is the right fix and it is not in this lane's files.** |
| 1007 | /// |
| 1008 | /// The suffix and not the contents, for the reason the Rust says: a rule about |
| 1009 | /// contents is one a reader must run over every byte before it can say whether |
| 1010 | /// there is a question to ask, and it answers differently on two builds. |
| 1011 | var CODE_SUFFIXES = ['.htm', '.html', '.js', '.mjs', '.svg', '.wasm']; |
| 1012 | |
| 1013 | /// Does this path name a file this build considers code? |
| 1014 | function isCodePath(path) { |
| 1015 | var lower = String(path || '').toLowerCase(); |
| 1016 | for (var i = 0; i < CODE_SUFFIXES.length; i++) { |
| 1017 | if (lower.length >= CODE_SUFFIXES[i].length |
| 1018 | && lower.slice(-CODE_SUFFIXES[i].length) === CODE_SUFFIXES[i]) return true; |
| 1019 | } |
| 1020 | return false; |
| 1021 | } |
| 1022 | |
| 1023 | /// What a template pack says about itself, without opening it. |
| 1024 | /// |
| 1025 | /// Answers `{ name, kind, files, code }` -- `code` being the paths `codePaths` |
| 1026 | /// picks out, so the import question and the share question are answered by |
| 1027 | /// ONE reading of what counts as code rather than by two. |
| 1028 | /// |
| 1029 | /// A pack presented as a reading of the same shape `describe` takes: the |
| 1030 | /// judgement is `codePaths`'s, called and not copied. |
| 1031 | function readTemplate(json) { |
| 1032 | var text = String(json || ''); |
| 1033 | if (!text.trim()) { |
| 1034 | throw new Error(tOr('tmpl.err_empty', |
| 1035 | 'That file is empty, so there is nothing to open.')); |
| 1036 | } |
| 1037 | var val; |
| 1038 | try { val = JSON.parse(text); } |
| 1039 | catch (e) { |
| 1040 | throw new Error(tOr('tmpl.err_not_template', |
| 1041 | 'That file is not a Daimond template.')); |
| 1042 | } |
| 1043 | if (!val || typeof val !== 'object' || !val.files || typeof val.files !== 'object') { |
| 1044 | throw new Error(tOr('tmpl.err_not_template', |
| 1045 | 'That file is not a Daimond template.')); |
| 1046 | } |
| 1047 | // Both maps: `files` is what round-trips as text and `binary` is everything |
| 1048 | // else, base64. A picture inside a template is still a file the person is |
| 1049 | // being given, so it is counted and it may be named. |
| 1050 | var paths = Object.keys(val.files); |
| 1051 | if (val.binary && typeof val.binary === 'object') { |
| 1052 | Object.keys(val.binary).forEach(function (p) { |
| 1053 | if (paths.indexOf(p) === -1) paths.push(p); |
| 1054 | }); |
| 1055 | } |
| 1056 | paths.sort(); |
| 1057 | // The SAME `codePaths` the sealed path uses, over a reading of the same |
| 1058 | // shape. A second sweep written here would be the second judgement about |
| 1059 | // what counts as code, live on the door with no signature behind it. |
| 1060 | var read = { |
| 1061 | count: function () { return paths.length; }, |
| 1062 | path: function (i) { return paths[i] || ''; }, |
| 1063 | isCode: function (i) { return isCodePath(paths[i] || ''); }, |
| 1064 | }; |
| 1065 | return { |
| 1066 | name: String(val.name || ''), |
| 1067 | kind: String(val.kind || 'diamond'), |
| 1068 | files: paths, |
| 1069 | code: codePaths(read), |
| 1070 | }; |
| 1071 | } |
| 1072 | |
| 1073 | /// Ask whether a page inside a template may be written into this workspace. |
| 1074 | /// |
| 1075 | /// `askAboutCode` is the sealed path's question and it cannot be used here: |
| 1076 | /// its sentence names WHO the share came from, by fingerprint, and a template |
| 1077 | /// has no author, no signature and no envelope -- there is nothing truthful to |
| 1078 | /// put in that half of the sentence. So the question is asked in the same |
| 1079 | /// place, in the same box, with the same refusal when there is nothing to ask |
| 1080 | /// with; what differs is the one clause that would have been a lie. |
| 1081 | /// |
| 1082 | /// ALL OR NOTHING, unlike a share. `accept` can land the data half of a share |
| 1083 | /// and leave the pages out; `import_template` has no such door, so declining |
| 1084 | /// means nothing is written at all, and the button says so. |
| 1085 | async function askAboutTemplate(desc) { |
| 1086 | var body = tOr('tmpl.code_body', |
| 1087 | '“{name}” includes a page: a program written by somebody else, which Daimond will ' |
| 1088 | + 'run when you open it. A template carries no signature and nobody’s name, so ' |
| 1089 | + 'nothing here can tell you where it came from — only the person who gave you the ' |
| 1090 | + 'file can.\n\nWhat would be added: {files}\n\nIt opens as a NEW Diamond and can ' |
| 1091 | + 'never write over one you already have. Declining writes nothing at all.', |
| 1092 | { name: desc.name || tOr('tmpl.unnamed', 'this template'), |
| 1093 | files: desc.code.join(', ') }); |
| 1094 | if (window.DaimondCore && typeof DaimondCore.confirm === 'function') { |
| 1095 | return await DaimondCore.confirm(body, tOr('tmpl.code_ok', 'Accept the page and open it'), |
| 1096 | { title: tOr('tmpl.code_title', 'This template contains code'), danger: true }); |
| 1097 | } |
| 1098 | // The same answer `askAboutCode` gives, and for the same reason: the fence |
| 1099 | // is the consent, not the dialog. A build with nothing to ask with refuses. |
| 1100 | log('no confirm dialog: refusing the template’s code'); |
| 1101 | return false; |
| 1102 | } |
| 1103 | |
| 1104 | /// Hand a template over as a file. Answers the name it was given. |
| 1105 | /// |
| 1106 | /// THE APP'S OWN HANDOVER, not a second one: one `Blob`, one object URL, a |
| 1107 | /// synthetic `<a download>`, and the URL revoked straight after -- exactly |
| 1108 | /// what `save` above does, because a second way of giving somebody a file |
| 1109 | /// would be a second thing to fix. |
| 1110 | function saveTemplate(name, json) { |
| 1111 | var text = String(json || ''); |
| 1112 | if (!text.trim()) { |
| 1113 | throw new Error(tOr('tmpl.err_nothing', |
| 1114 | 'There is nothing to save: that Diamond made an empty template.')); |
| 1115 | } |
| 1116 | var stem = String(name || 'template') |
| 1117 | .replace(/[^A-Za-z0-9 _-]+/g, '').trim().replace(/\s+/g, '-').slice(0, 40); |
| 1118 | var file = (stem || 'template') + TEMPLATE_EXT; |
| 1119 | var a = document.createElement('a'); |
| 1120 | a.href = URL.createObjectURL(new Blob([text], { type: TEMPLATE_MIME })); |
| 1121 | a.download = file; |
| 1122 | a.rel = 'noopener'; |
| 1123 | a.click(); |
| 1124 | URL.revokeObjectURL(a.href); |
| 1125 | log('saved template', file, text.length, 'bytes'); |
| 1126 | return file; |
| 1127 | } |
| 1128 | |
| 1129 | /// Open a template's text: ask about any page in it, then open it as a NEW |
| 1130 | /// Diamond. Answers `{ id, name, files, code }`. |
| 1131 | /// |
| 1132 | /// The question comes BEFORE the call and there is nothing after it to undo: |
| 1133 | /// `import_template` writes as soon as it is reached. |
| 1134 | async function takeTemplate(json) { |
| 1135 | var desc = readTemplate(json); |
| 1136 | if (desc.code.length) { |
| 1137 | var yes = await askAboutTemplate(desc); |
| 1138 | if (!yes) { |
| 1139 | return { ok: false, why: tOr('tmpl.declined', |
| 1140 | 'The page was not accepted, so nothing has been opened.'), code: desc.code }; |
| 1141 | } |
| 1142 | } |
| 1143 | if (!window.DaimondDiamond || typeof DaimondDiamond.openTemplate !== 'function') { |
| 1144 | throw new Error(tOr('tmpl.err_no_door', |
| 1145 | 'This build can read a template but has nowhere to open one.')); |
| 1146 | } |
| 1147 | var id = await DaimondDiamond.openTemplate(json); |
| 1148 | log('opened template as', id, desc.files.length, 'files'); |
| 1149 | return { ok: true, id: id, name: desc.name, files: desc.files, code: desc.code }; |
| 1150 | } |
| 1151 | |
| 1152 | /// Ask for a template from the machine, and open what is chosen. |
| 1153 | /// |
| 1154 | /// Must be called from a click, for the reason `pick` gives: an |
| 1155 | /// `<input type="file">` opens nothing without a user gesture, and that is the |
| 1156 | /// browser's rule and the right one. |
| 1157 | function pickTemplate() { |
| 1158 | return new Promise(function (resolve, reject) { |
| 1159 | if (typeof document === 'undefined' || !document.body) { |
| 1160 | reject(new Error(tOr('tmpl.err_no_file', |
| 1161 | 'No file was chosen, so nothing was opened.'))); |
| 1162 | return; |
| 1163 | } |
| 1164 | var input = document.createElement('input'); |
| 1165 | input.type = 'file'; |
| 1166 | // Both, for the reason `pick` gives: a browser matches the extension and |
| 1167 | // an operating system that has never seen a `.dtemplate` matches the type. |
| 1168 | input.accept = TEMPLATE_EXT + ',' + TEMPLATE_MIME; |
| 1169 | input.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px'; |
| 1170 | var done = false; |
| 1171 | function finish(fn, arg) { |
| 1172 | if (done) return; |
| 1173 | done = true; |
| 1174 | try { input.remove(); } catch (e) { /* already gone */ } |
| 1175 | fn(arg); |
| 1176 | } |
| 1177 | input.addEventListener('change', function () { |
| 1178 | var f = input.files && input.files[0]; |
| 1179 | if (!f) { |
| 1180 | finish(reject, new Error(tOr('tmpl.err_no_file', |
| 1181 | 'No file was chosen, so nothing was opened.'))); |
| 1182 | return; |
| 1183 | } |
| 1184 | if (f.size > TEMPLATE_MAX) { |
| 1185 | finish(reject, new Error(tOr('tmpl.err_file_huge', |
| 1186 | 'That file is {size}, which is larger than any template can be, so it ' |
| 1187 | + 'was not opened.', { size: kb(f.size) }))); |
| 1188 | return; |
| 1189 | } |
| 1190 | // Resolved with the PROMISE of the opening, so a caller awaiting this is |
| 1191 | // awaiting the whole of it -- the consent question included. |
| 1192 | f.text().then(function (text) { return takeTemplate(text); }) |
| 1193 | .then(function (r) { finish(resolve, r); }, |
| 1194 | function (e) { finish(reject, e); }); |
| 1195 | }); |
| 1196 | // A chooser somebody closed still has to SETTLE: a promise left pending is |
| 1197 | // a button that never comes back. |
| 1198 | input.addEventListener('cancel', function () { |
| 1199 | finish(reject, new Error(tOr('tmpl.err_no_file', |
| 1200 | 'No file was chosen, so nothing was opened.'))); |
| 1201 | }); |
| 1202 | document.body.appendChild(input); |
| 1203 | input.click(); |
| 1204 | }); |
| 1205 | } |
| 1206 | |
| 1207 | // ── The panel ────────────────────────────────────────────── |
| 1208 | // |
| 1209 | // EVERYTHING ABOVE THIS LINE WAS COMPLETE AND UNREACHABLE. share.js could |
| 1210 | // collect a Diamond, sign it, seal it to one person, choose a carrier, write a |
| 1211 | // `.dshare` out and read one back in -- and no button anywhere in Daimond |
| 1212 | // called any of it. A module with no production caller is not done, and this |
| 1213 | // project had shipped that failure three times before this one. Forty checks |
| 1214 | // passing against a surface a user cannot reach prove only that the surface |
| 1215 | // works. |
| 1216 | // |
| 1217 | // So this is the Share view of the Social panel, and it renders into |
| 1218 | // `#social-share-list` exactly as post.js renders into the messages list: the |
| 1219 | // chip, the head and the empty line belong to improve.js, and everything below |
| 1220 | // the line is this file's. The two halves of the feature are both here, in the |
| 1221 | // order a person meets them -- taking one in needs nothing but the file, and |
| 1222 | // sending one needs a Diamond and somebody to send it to. |
| 1223 | |
| 1224 | var HOST = '#social-share-list'; |
| 1225 | var VIEW = 'share'; |
| 1226 | |
| 1227 | function host() { return document.querySelector(HOST); } |
| 1228 | |
| 1229 | function node(tag, cls, text) { |
| 1230 | var n = document.createElement(tag); |
| 1231 | if (cls) n.className = cls; |
| 1232 | if (text != null) n.textContent = text; |
| 1233 | return n; |
| 1234 | } |
| 1235 | |
| 1236 | /// The line under the chip goes away exactly when there is something to read. |
| 1237 | function said(n) { |
| 1238 | try { |
| 1239 | if (window.DaimondSocial && DaimondSocial.filled) DaimondSocial.filled(VIEW, n); |
| 1240 | } catch (e) { /* no panel shell */ } |
| 1241 | } |
| 1242 | |
| 1243 | /// Draw the view. Cleared wholesale every time, so nothing belonging to |
| 1244 | /// anything else may be parked inside it. |
| 1245 | function render() { |
| 1246 | var h = host(); |
| 1247 | if (!h) return; |
| 1248 | h.textContent = ''; |
| 1249 | var stop = why(); |
| 1250 | if (stop) { |
| 1251 | // The off-line carries the REASON rather than a generic absence: the |
| 1252 | // three ways this can be unavailable fail differently and a person |
| 1253 | // deserves to know which. |
| 1254 | var off = document.getElementById('social-share-off'); |
| 1255 | if (off) off.textContent = stop; |
| 1256 | said(0); |
| 1257 | return; |
| 1258 | } |
| 1259 | h.appendChild(takeBlock()); |
| 1260 | h.appendChild(sendBlock()); |
| 1261 | // LAST, because it is the odd one out here and the ordering says so: the two |
| 1262 | // blocks above are a share -- one named person to another, sealed. A template |
| 1263 | // is neither sealed nor addressed, and it is in this view because this is the |
| 1264 | // one place in Daimond where something arrives AS A FILE and is asked about |
| 1265 | // before it is written. A person who has just read "Open a share file…" is |
| 1266 | // looking at the right shelf for "Open a template". |
| 1267 | h.appendChild(templateBlock()); |
| 1268 | said(1); |
| 1269 | } |
| 1270 | |
| 1271 | /// Taking one in. First, because it needs nothing of the user but the file. |
| 1272 | function takeBlock() { |
| 1273 | var box = node('div', 'shr-block'); |
| 1274 | box.appendChild(node('h3', 'shr-head', tOr('share.panel_take_head', 'Open a share'))); |
| 1275 | box.appendChild(node('p', 'shr-note', tOr('share.panel_take_help', |
| 1276 | 'Take a {ext} somebody gave you. A page inside it is a program they wrote, and ' |
| 1277 | + 'it is never written into your workspace without asking you first.', |
| 1278 | { ext: EXT }))); |
| 1279 | var say = node('p', 'shr-say'); |
| 1280 | say.hidden = true; |
| 1281 | var b = node('button', 'shr-btn shr-take', tOr('share.panel_take', 'Open a share file…')); |
| 1282 | b.type = 'button'; |
| 1283 | b.addEventListener('click', function () { |
| 1284 | say.className = 'shr-say'; |
| 1285 | say.textContent = ''; |
| 1286 | say.hidden = true; |
| 1287 | // `pick` MUST be called from the click, which is why it is called here |
| 1288 | // and not through a helper that awaits something first: an |
| 1289 | // `<input type="file">` opens nothing without a user gesture. |
| 1290 | pick().then(function (r) { |
| 1291 | if (!r || !r.ok) { |
| 1292 | // The total refusal -- everything in it was a page and the page was |
| 1293 | // declined -- carries its own sentence, and it is not an error. |
| 1294 | say.className = 'shr-say'; |
| 1295 | say.textContent = (r && r.why) || tOr('share.err_all_code', |
| 1296 | 'Everything in that share is a page, and the page was not accepted, so ' |
| 1297 | + 'nothing has been added.'); |
| 1298 | say.hidden = false; |
| 1299 | return; |
| 1300 | } |
| 1301 | // WHAT LANDED AND WHAT DID NOT, in one line each. `said` on the |
| 1302 | // result is '' when everything arrived; when it is not, it names the |
| 1303 | // files that were left out, and a receiver who was never told cannot |
| 1304 | // go looking for them. |
| 1305 | say.className = 'shr-say'; |
| 1306 | say.textContent = tOr('share.landed_ok', |
| 1307 | 'Added as a Diamond of your own. {n} file(s) arrived.', |
| 1308 | { n: r.wrote.length }); |
| 1309 | say.hidden = false; |
| 1310 | if (r.said) { |
| 1311 | var more = node('p', 'shr-say shr-warn', r.said); |
| 1312 | say.parentNode.appendChild(more); |
| 1313 | } |
| 1314 | }, function (e) { |
| 1315 | say.className = 'shr-say shr-warn'; |
| 1316 | say.textContent = (e && e.message) ? e.message : String(e); |
| 1317 | say.hidden = false; |
| 1318 | }); |
| 1319 | }); |
| 1320 | box.appendChild(b); |
| 1321 | box.appendChild(say); |
| 1322 | return box; |
| 1323 | } |
| 1324 | |
| 1325 | /// Opening a template. Its counterpart -- SAVING one -- is behind the cog on |
| 1326 | /// the Diamond it is made from, because that is a fact about one Diamond and |
| 1327 | /// this is not about any. |
| 1328 | function templateBlock() { |
| 1329 | var box = node('div', 'shr-block'); |
| 1330 | box.appendChild(node('h3', 'shr-head', tOr('tmpl.panel_head', 'Open a template'))); |
| 1331 | // The two facts a person cannot guess, said before they press anything |
| 1332 | // rather than in the dialog afterwards. |
| 1333 | box.appendChild(node('p', 'shr-note', tOr('tmpl.panel_help', |
| 1334 | 'A template is a Diamond’s shape without its contents: the page it draws through ' |
| 1335 | + 'and its automation, and none of what it has recorded. It opens as a NEW ' |
| 1336 | + 'Diamond and can never write over one you already have. Triggered actions are ' |
| 1337 | + 'never carried, because a trigger fires with nobody pressing anything.'))); |
| 1338 | var say = node('p', 'shr-say'); |
| 1339 | say.hidden = true; |
| 1340 | var b = node('button', 'shr-btn shr-tmpl', tOr('tmpl.panel_open', 'Open a template file…')); |
| 1341 | b.type = 'button'; |
| 1342 | b.addEventListener('click', function () { |
| 1343 | say.className = 'shr-say'; |
| 1344 | say.textContent = ''; |
| 1345 | say.hidden = true; |
| 1346 | // From the click, for the reason `takeBlock` gives: an |
| 1347 | // `<input type="file">` opens nothing without a user gesture. |
| 1348 | pickTemplate().then(function (r) { |
| 1349 | say.className = 'shr-say'; |
| 1350 | if (!r || !r.ok) { |
| 1351 | // Declining the page is not an error and is not drawn as one. |
| 1352 | say.textContent = (r && r.why) || tOr('tmpl.declined', |
| 1353 | 'The page was not accepted, so nothing has been opened.'); |
| 1354 | say.hidden = false; |
| 1355 | return; |
| 1356 | } |
| 1357 | say.textContent = tOr('tmpl.opened', |
| 1358 | 'Opened as a new Diamond, “{name}”. {n} file(s) arrived.', |
| 1359 | { name: r.name || '', n: r.files.length }); |
| 1360 | say.hidden = false; |
| 1361 | }, function (e) { |
| 1362 | say.className = 'shr-say shr-warn'; |
| 1363 | say.textContent = (e && e.message) ? e.message : String(e); |
| 1364 | say.hidden = false; |
| 1365 | }); |
| 1366 | }); |
| 1367 | box.appendChild(b); |
| 1368 | box.appendChild(say); |
| 1369 | return box; |
| 1370 | } |
| 1371 | |
| 1372 | /// Sending one. The Diamond is the one being worked, because that is the |
| 1373 | /// gesture -- you are looking at something and you give somebody a copy -- |
| 1374 | /// and because a picker of every Diamond would be a second Diamonds list in a |
| 1375 | /// panel that is not the rail. |
| 1376 | function sendBlock() { |
| 1377 | var box = node('div', 'shr-block'); |
| 1378 | box.appendChild(node('h3', 'shr-head', tOr('share.panel_send_head', 'Send a Diamond'))); |
| 1379 | |
| 1380 | var cur = null; |
| 1381 | try { |
| 1382 | if (window.DaimondDiamond && DaimondDiamond.current) cur = DaimondDiamond.current(); |
| 1383 | } catch (e) { cur = null; } |
| 1384 | if (!cur || !cur.id) { |
| 1385 | box.appendChild(node('p', 'shr-note', tOr('share.panel_no_diamond', |
| 1386 | 'Open a Diamond to share it. A share carries the files of one Diamond, so ' |
| 1387 | + 'there has to be one in front of you.'))); |
| 1388 | return box; |
| 1389 | } |
| 1390 | |
| 1391 | var folk = []; |
| 1392 | try { |
| 1393 | if (window.DaimondPost && DaimondPost.people) folk = DaimondPost.people() || []; |
| 1394 | } catch (e) { folk = []; } |
| 1395 | folk = folk.filter(function (p) { return p && p.pub && p.enc; }); |
| 1396 | if (!folk.length) { |
| 1397 | // A sealing key is what a share needs, and a person known by signing key |
| 1398 | // alone has not got one. Said in those terms rather than "nobody yet", |
| 1399 | // because the fix is specific: swap codes. |
| 1400 | box.appendChild(node('p', 'shr-note', tOr('share.panel_no_people', |
| 1401 | 'Nobody here has a sealing key yet, so there is nobody a share can be ' |
| 1402 | + 'sealed to. Show somebody your code, or read theirs.'))); |
| 1403 | return box; |
| 1404 | } |
| 1405 | |
| 1406 | box.appendChild(node('p', 'shr-note', tOr('share.panel_this', |
| 1407 | 'Sharing “{name}” — a copy they will own, not a view of yours.', |
| 1408 | { name: cur.name || cur.id }))); |
| 1409 | |
| 1410 | var pickWho = node('select', 'shr-who'); |
| 1411 | pickWho.setAttribute('aria-label', tOr('share.panel_who', 'Who it goes to')); |
| 1412 | for (var i = 0; i < folk.length; i++) { |
| 1413 | var o = node('option', null, folk[i].label || fingerprintOf(keyBytes(folk[i].pub))); |
| 1414 | o.value = folk[i].pub; |
| 1415 | pickWho.appendChild(o); |
| 1416 | } |
| 1417 | |
| 1418 | var say = node('p', 'shr-say'); |
| 1419 | say.hidden = true; |
| 1420 | var extra = node('p', 'shr-say'); |
| 1421 | extra.hidden = true; |
| 1422 | |
| 1423 | var go = node('button', 'shr-btn shr-send', tOr('share.panel_send', 'Share')); |
| 1424 | go.type = 'button'; |
| 1425 | go.addEventListener('click', function () { |
| 1426 | var who = null; |
| 1427 | for (var j = 0; j < folk.length; j++) { |
| 1428 | if (folk[j].pub === pickWho.value) { who = folk[j]; break; } |
| 1429 | } |
| 1430 | if (!who) return; |
| 1431 | go.disabled = true; |
| 1432 | extra.hidden = true; |
| 1433 | extra.textContent = ''; |
| 1434 | say.className = 'shr-say'; |
| 1435 | say.textContent = tOr('share.panel_sealing', 'Sealing…'); |
| 1436 | say.hidden = false; |
| 1437 | sendTo(cur, who, say, extra).then(function () { go.disabled = false; }, |
| 1438 | function (e) { |
| 1439 | say.className = 'shr-say shr-warn'; |
| 1440 | say.textContent = (e && e.message) ? e.message : String(e); |
| 1441 | say.hidden = false; |
| 1442 | go.disabled = false; |
| 1443 | }); |
| 1444 | }); |
| 1445 | |
| 1446 | var row = node('div', 'shr-row'); |
| 1447 | row.appendChild(pickWho); |
| 1448 | row.appendChild(go); |
| 1449 | box.appendChild(row); |
| 1450 | box.appendChild(say); |
| 1451 | box.appendChild(extra); |
| 1452 | return box; |
| 1453 | } |
| 1454 | |
| 1455 | /// Compose a share of `cur` to `who`, and hand it to whichever carrier fits. |
| 1456 | /// |
| 1457 | /// THE CARRIER IS CHOSEN AND THE CHOICE IS SAID. A person watching this needs |
| 1458 | /// to know which happened, because the two ask different things of them: a |
| 1459 | /// relay send is finished when it says so, and a file is finished when they |
| 1460 | /// have given somebody the file. |
| 1461 | async function sendTo(cur, who, say, extra) { |
| 1462 | var made = await compose({ |
| 1463 | name: cur.name || cur.id, |
| 1464 | diamond: cur.id, |
| 1465 | to: who.pub, |
| 1466 | toEnc: who.enc, |
| 1467 | }); |
| 1468 | var name = who.label || fingerprintOf(keyBytes(who.pub)); |
| 1469 | if (carrier(made) === 'file') { |
| 1470 | var file = save(made); |
| 1471 | say.className = 'shr-say'; |
| 1472 | say.textContent = savedSaid(file); |
| 1473 | say.hidden = false; |
| 1474 | extra.className = 'shr-say'; |
| 1475 | extra.textContent = carrierWhy(made); |
| 1476 | extra.hidden = false; |
| 1477 | return; |
| 1478 | } |
| 1479 | // The relay. `DaimondPost.fanout` is the door a message and a group roster |
| 1480 | // both take, called and not copied: a second POST written here would be a |
| 1481 | // second place for the relay's refusals to lose their words, which is |
| 1482 | // exactly what happened to the group send once already. |
| 1483 | if (!window.DaimondPost || typeof DaimondPost.fanout !== 'function') { |
| 1484 | var only = save(made); |
| 1485 | say.className = 'shr-say'; |
| 1486 | say.textContent = savedSaid(only); |
| 1487 | say.hidden = false; |
| 1488 | return; |
| 1489 | } |
| 1490 | var r = await DaimondPost.fanout(made, [who.pub]); |
| 1491 | if (r && r.sent) { |
| 1492 | say.className = 'shr-say'; |
| 1493 | say.textContent = tOr('share.panel_sent', 'Sent to {who}.', { who: name }); |
| 1494 | say.hidden = false; |
| 1495 | return; |
| 1496 | } |
| 1497 | // REFUSED, AND THE REASON, AND WHAT TO DO INSTEAD. `fanout` answers a |
| 1498 | // `why` per recipient and a caller that read only `sent` would report |
| 1499 | // nothing at all -- the same defect as a landing that counts what it left |
| 1500 | // out and says none of it. |
| 1501 | var bad = (r && r.refused && r.refused[0]) || null; |
| 1502 | say.className = 'shr-say shr-warn'; |
| 1503 | say.textContent = tOr('share.panel_refused', |
| 1504 | 'The relay would not take it: {why} It is saved as a file instead — give them ' |
| 1505 | + 'that.', { why: (bad && bad.why) ? bad.why : '' }); |
| 1506 | say.hidden = false; |
| 1507 | var fell = save(made); |
| 1508 | extra.className = 'shr-say'; |
| 1509 | extra.textContent = savedSaid(fell); |
| 1510 | extra.hidden = false; |
| 1511 | } |
| 1512 | |
| 1513 | // ── Wiring ───────────────────────────────────────────────── |
| 1514 | |
| 1515 | function attachPanel() { |
| 1516 | if (!host()) return false; |
| 1517 | try { |
| 1518 | if (window.DaimondSocial && DaimondSocial.watch) { |
| 1519 | DaimondSocial.watch(function (view) { if (view === VIEW) render(); }); |
| 1520 | } |
| 1521 | } catch (e) { /* no panel to watch */ } |
| 1522 | // Say the panel's own words again in a new language. Every string here is |
| 1523 | // built rather than marked up, so a language change reaches none of them |
| 1524 | // unless this surface is registered -- the trap post.js names in the same |
| 1525 | // words three hundred lines above its own registration. |
| 1526 | try { |
| 1527 | DaimondI18n.surface(function () { return host(); }, function () { render(); }); |
| 1528 | } catch (e) { /* no i18n in this build */ } |
| 1529 | render(); |
| 1530 | return true; |
| 1531 | } |
| 1532 | |
| 1533 | if (typeof document !== 'undefined') { |
| 1534 | if (document.readyState === 'loading') { |
| 1535 | document.addEventListener('DOMContentLoaded', function () { attachPanel(); }); |
| 1536 | } else if (!attachPanel()) { |
| 1537 | document.addEventListener('DOMContentLoaded', function () { attachPanel(); }); |
| 1538 | } |
| 1539 | // The Diamond being worked decides what the Send half offers, so a change of |
| 1540 | // Diamond redraws it. Without this the panel offers to share whatever was |
| 1541 | // open when it was last drawn, which is a share of the wrong thing. |
| 1542 | document.addEventListener('daimond-diamond-changed', function () { |
| 1543 | try { if (host()) render(); } catch (e) { /* nothing drawn yet */ } |
| 1544 | }); |
| 1545 | } |
| 1546 | |
| 1547 | // ── Public surface ───────────────────────────────────────── |
| 1548 | window.DaimondShare = { |
| 1549 | /// Whether this build can share at all, and why not. A caller drawing a |
| 1550 | /// disabled control needs the sentence, not the boolean. |
| 1551 | ready: ready, |
| 1552 | why: why, |
| 1553 | /// The three parts, separately, so a caller can draw between them. |
| 1554 | collect: collect, |
| 1555 | compose: compose, |
| 1556 | open: openSealed, |
| 1557 | accept: accept, |
| 1558 | /// The whole receiving side in one call. |
| 1559 | receive: receive, |
| 1560 | /// What a reading says, without holding the wasm object open. |
| 1561 | describe: describe, |
| 1562 | codePaths: codePaths, |
| 1563 | /// The consent question on its own, for a caller that has already read a |
| 1564 | /// share and wants to ask before doing anything else with it. |
| 1565 | askAboutCode: askAboutCode, |
| 1566 | /// What a sealed share is called as a file, and the extension it wears. |
| 1567 | filename: filename, |
| 1568 | ext: EXT, |
| 1569 | mime: MIME, |
| 1570 | /// Which carrier a composed share must take, and the sentence that says |
| 1571 | /// why. The relay refuses one over 64 KiB and a capp page is about that on |
| 1572 | /// its own, so this is not a corner case. |
| 1573 | carrier: carrier, |
| 1574 | carrierWhy: carrierWhy, |
| 1575 | fitsRelay: fitsRelay, |
| 1576 | /// The file route, both ways. `save` writes one out; `take` reads one in |
| 1577 | /// from a `File`, a `Blob`, bytes or a base64 envelope; `pick` asks for one |
| 1578 | /// and must be called from a click. All three keep the consent step: a capp |
| 1579 | /// arriving by file is asked about exactly as one arriving by relay is. |
| 1580 | save: save, |
| 1581 | savedSaid: savedSaid, |
| 1582 | take: take, |
| 1583 | pick: pick, |
| 1584 | /// The panel. `render` is published so a verifier draws the view the way the |
| 1585 | /// chip does rather than through a second path written for it. |
| 1586 | render: render, |
| 1587 | view: VIEW, |
| 1588 | /// A template: the shape of a Diamond, unsealed, opened by whoever holds |
| 1589 | /// the file. `saveTemplate` writes one out; `readTemplate` says what is in |
| 1590 | /// one without opening it; `takeTemplate` asks about any page in it and |
| 1591 | /// then opens it as a NEW Diamond; `pickTemplate` asks for the file and |
| 1592 | /// must be called from a click. The consent question is on the WRITE, as |
| 1593 | /// it is for a share, and `import_template` has no half-landing behind it. |
| 1594 | saveTemplate: saveTemplate, |
| 1595 | readTemplate: readTemplate, |
| 1596 | takeTemplate: takeTemplate, |
| 1597 | pickTemplate: pickTemplate, |
| 1598 | askAboutTemplate: askAboutTemplate, |
| 1599 | templateExt: TEMPLATE_EXT, |
| 1600 | /// This build's reading of what counts as code, by suffix. Published |
| 1601 | /// because it is the ONE answer in JavaScript and a second caller must |
| 1602 | /// reach it rather than write another -- see its own note for why it is a |
| 1603 | /// mirror of `fe2o3_sbj::share::is_code_path` at all. |
| 1604 | isCodePath: isCodePath, |
| 1605 | /// The schema, and the ceilings, for a panel that wants to say them. |
| 1606 | schema: SCHEMA, |
| 1607 | limits: { files: FILES_MAX, bytes: TOTAL_MAX, note: NOTE_MAX, |
| 1608 | relay: RELAY_MAX, file: FILE_MAX }, |
| 1609 | }; |
| 1610 | })(); |