Oregami
Repositories/oxedyne/daimond

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