Skip to main content

max / goingson

42.1 KB · 1146 lines History Blame Raw
1 //! Groups: who you share with, and the key an admin needs to add you.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # The reads are local
6 //!
7 //! synckit writes the group directory down: `sync_groups` and
8 //! `sync_group_members` keep what the sync loop asks the server for, and
9 //! `synckit_client::store::directory` reads it synchronously. The remote read
10 //! happens on a schedule, outside the request loop, which is where a
11 //! description can live with it. So this section states how old its answers are
12 //! rather than pretending they are live.
13 //!
14 //! # What is here
15 //!
16 //! The groups this device knows you belong to, which of them you administer,
17 //! who is in those, and your identity public key with its fingerprint.
18 //!
19 //! **The whole member side of the flow is here.** Being added to a group takes
20 //! no write from the person being added: they show their public key, an admin
21 //! seals the group key to it. Both facts a member needs are local crypto rather
22 //! than a request; `my_group_pubkey` and `my_group_fingerprint` are `async` by
23 //! declaration only, and neither body awaits.
24 //!
25 //! # The admin writes are queued rather than performed
26 //!
27 //! Creating a group, adding a member and removing one each open a conversation
28 //! with a server, and a handler cannot await. So the description says "queue
29 //! this" and [`crate::group_queue`] drains it a minute later.
30 //!
31 //! What that buys, beyond making the controls sayable at all: the write
32 //! survives being offline, a failure is a row with the server's reason on it
33 //! sitting in this section rather than a toast that has gone, and `add_member`
34 //! can be asked for before the master key is loaded, because the queue turns
35 //! "not right now" into "once you unlock".
36 //!
37 //! The queue is shown. A control whose effect is a minute away has to be, or
38 //! the section looks like it lost the request.
39 //!
40 //! # The invitation flow, and where its one secret lives
41 //!
42 //! Six commands and a state machine rather than one call. Five of the six queue
43 //! exactly as the three above do; the sixth, listing, is a directory read.
44 //!
45 //! **The token is not on the queue row, and that is the load-bearing part.**
46 //! `create_invitation` returns a code the server keeps only a hash of, so what
47 //! comes back is the only copy that will ever exist. A queue row is swept an
48 //! hour after it lands, so putting the code there would destroy it on a timer;
49 //! the drainer writes it into `sync_invitations` through
50 //! [`directory::record_issued`] the instant it arrives, and the section reads it
51 //! from there.
52 //!
53 //! The mirror of it: a code the *user* pasted is on the queue row
54 //! (`invite_token`), because nothing on this device is the authority on it and
55 //! the drainer has to read it a minute later. Two things called a token, one
56 //! written down here and one never.
57 //!
58 //! # Confirming is the security of the flow, so it sits at the top
59 //!
60 //! An accepted invitation is a person waiting on a fingerprint comparison, and
61 //! nothing about them reaches the group until an admin makes it. That is the
62 //! only step here where somebody is actively blocked and the only one where
63 //! getting it wrong admits the wrong key, so it is drawn above the group list
64 //! rather than found by opening each group, and the fingerprint is on the row's
65 //! face rather than behind a menu.
66 //!
67 //! A queued confirm is drawn with the fingerprint too, resolved from the
68 //! directory as the row is drawn. That is a display of the server's current
69 //! answer; the queue row itself carries only the invitation's id, so the
70 //! drainer re-reads rather than acting on a value copied a minute ago.
71 //!
72 //! # Two drains, and the section says so
73 //!
74 //! Previewing a code and accepting it are separate queued writes, so a paste
75 //! takes up to two minutes to become a membership request. Collapsing them into
76 //! one op would remove the step where a person reads what they are joining
77 //! before they join it, which is the point of a preview. The hint says the wait
78 //! out loud instead.
79 //!
80 //! # What is not here
81 //!
82 //! A copy control on a pending code. Nothing in the vocabulary names "copy this
83 //! value and say so briefly" (quasicoherent `c3e145e0`), so the token is drawn
84 //! as a field, which is selectable everywhere and is something a host with a
85 //! copy affordance can attach it to. Same answer, same reason, as the identity
86 //! key below.
87
88 use makeover_layout::Tone;
89 use quasi_declare::declare;
90 use quasi_router::screen::{Act, Choice, Tag};
91 use quasi_router::{Action, RouteError};
92 use synckit_client::InvitationState;
93 use synckit_client::store::directory;
94
95 use crate::commands::group::normalize_invite_token;
96 use crate::state::AppState;
97
98 /// Read a connection out of the pool, or say so in the class that means it.
99 ///
100 /// Returned behind [`DerefMut`](std::ops::DerefMut) rather than by its real
101 /// type, so this module does not have to name r2d2's pooled-connection generic
102 /// to say "a connection".
103 fn conn(
104 app: &AppState,
105 ) -> Result<impl std::ops::DerefMut<Target = rusqlite::Connection>, RouteError> {
106 app.db
107 .conn()
108 .map_err(|error| RouteError::internal(error.to_string()))
109 }
110
111 /// The word for a state, and there is no catch-all.
112 ///
113 /// `InvitationState` is `#[non_exhaustive]`, so a variant a newer server knows
114 /// about arrives here as something this build cannot name. Saying so beats
115 /// picking the nearest word: every state below decides whether acts are
116 /// offered, and guessing wrong offers an act on an invitation that cannot take
117 /// it.
118 fn state_word(state: InvitationState) -> &'static str {
119 match state {
120 InvitationState::Pending => "Waiting to be used",
121 InvitationState::Accepted => "Waiting on you",
122 InvitationState::Redeemed => "Used",
123 InvitationState::Revoked => "Cancelled",
124 InvitationState::Expired => "Expired",
125 _ => "In a state this version does not know",
126 }
127 }
128
129 /// One member of a group this user administers.
130 struct Member {
131 /// Their account email, which is what the row is called.
132 email: String,
133 /// Whether they administer it too.
134 is_admin: bool,
135 }
136
137 /// One invitation to a group this user administers, minus the accepted ones:
138 /// those are drawn at the top of the pane instead.
139 ///
140 /// Every field is read off
141 /// [`effective_state`](directory::KnownInvitation::effective_state) rather than
142 /// the stored `state`: the directory refreshes on a cycle and a deadline passes
143 /// on its own, so between two refreshes the stored value says `Pending` for a
144 /// code that has stopped working. Offering that as a live code is the one
145 /// failure this section can produce without anybody doing anything.
146 struct Invitation {
147 /// The group it admits somebody to, which the Cancel address names.
148 group: String,
149 /// The invitation, which the Cancel address names and the code field is
150 /// keyed by.
151 id: String,
152 /// What the badge says about where it has got to.
153 state: &'static str,
154 /// Whether it can still be used, which is the only state with a code, a
155 /// deadline and a Cancel.
156 usable: bool,
157 /// When it was issued.
158 issued: String,
159 /// When it stops working.
160 expires: String,
161 /// The one-use code, held only while pending and only on the device that
162 /// issued it. `write_invitations` drops the column at the same moment the
163 /// screen stops drawing it, so this is the screen agreeing with the store
164 /// rather than a second rule.
165 token: Option<String>,
166 }
167
168 /// One group this device knows the user belongs to.
169 struct Group {
170 /// What it is called.
171 name: String,
172 /// Whether this user administers it, which is what decides whether the
173 /// server will hand over a member list at all.
174 is_admin: bool,
175 /// Its members. Empty means the fetch has not landed, which is a different
176 /// fact from having none, and the pane says so in words.
177 members: Vec<Member>,
178 /// Its invitations, once it is administered.
179 invitations: Vec<Invitation>,
180 }
181
182 /// One invitation waiting on this admin to compare a fingerprint.
183 struct Confirmation {
184 /// The group, which the two addresses name.
185 group: String,
186 /// The invitation, which the two addresses name.
187 id: String,
188 /// Who accepted, or "Somebody" when the server did not say.
189 who: String,
190 /// The fingerprint to read out, if the key yielded one.
191 ///
192 /// Absent must never be confirmable: the comparison is the whole security
193 /// of the step, and a row that offers Confirm beside "unavailable" invites
194 /// approving something nobody read.
195 fingerprint: Option<String>,
196 }
197
198 /// One queued write that has not landed yet.
199 struct Queued {
200 /// The op, which Cancel names.
201 id: String,
202 /// What it will do, with the fingerprint a queued confirm authorizes
203 /// resolved as the row is drawn rather than copied when it was queued.
204 doing: String,
205 /// Whether the drainer has finished with it.
206 done: bool,
207 /// The server's own words, when it refused. A queue that reported "failed"
208 /// and kept the reason would be worse than the toast it replaced.
209 failed: Option<String>,
210 }
211
212 /// This device's identity key, or why there is none.
213 enum Identity {
214 /// Sync is not set up, so no key has been derived.
215 Unconfigured,
216 /// Set up, with no master key loaded yet.
217 Locked,
218 /// The public key, and the fingerprint to read it out by.
219 Held {
220 /// The key itself, shown in a field so it is selectable and so a host
221 /// with a copy affordance has something to attach one to.
222 pubkey: String,
223 /// Its fingerprint, or "unavailable" when it would not hash.
224 fingerprint: String,
225 },
226 }
227
228 /// The invitation this device has previewed, if it has one.
229 struct Held {
230 /// The group it leads to.
231 group: String,
232 /// Who issued it.
233 inviter: String,
234 /// When it stops working.
235 expires: String,
236 /// Whether it can still be accepted.
237 usable: bool,
238 /// Why not, when it cannot. `redeemable` is false for every terminal state
239 /// alike, so which one it is has to be read off the state: "that code has
240 /// expired" and "that code was cancelled" send a person to different
241 /// places.
242 refused: String,
243 }
244
245 /// Everything the Sharing pane draws, read once.
246 pub(super) struct Sharing {
247 /// The groups this device knows about.
248 groups: Vec<Group>,
249 /// How old that list is, if it has ever arrived. Staleness stated rather
250 /// than hidden: the list is a copy of the server's answer, and how old the
251 /// copy is decides how much to trust it.
252 refreshed: Option<String>,
253 /// Invitations waiting on this admin, across every group they administer.
254 confirmations: Vec<Confirmation>,
255 /// How old *that* list is, which is not the same number.
256 ///
257 /// `pending_confirmations_refreshed_at` is the freshness of exactly the
258 /// rows `pending_confirmations` returned, and it reports the stalest group
259 /// it drew from: `write_invitations` is scoped per group so one group's
260 /// failed fetch does not blank the others, which is also the cycle where a
261 /// number about the group list would overstate this one. A section whose
262 /// whole job is a security decision should not do that.
263 confirmations_refreshed: Option<String>,
264 /// Writes queued and not landed.
265 queued: Vec<Queued>,
266 /// Whether sync is set up at all, which is what every write here needs.
267 configured: bool,
268 /// The groups this user administers, as the two pickers offer them.
269 administered: Vec<Choice>,
270 /// The invite code this device has read, if it has read one.
271 held: Option<Held>,
272 /// This device's identity key.
273 identity: Identity,
274 }
275
276 /// The directory, read through the app's own pool.
277 ///
278 /// An empty answer is "this device knows of no groups", which is a true
279 /// statement about what has reached it rather than a claim that the user
280 /// belongs to none. The pane says so in those words.
281 pub(super) fn read(app: &AppState) -> Result<Sharing, RouteError> {
282 let conn = conn(app)?;
283 let known =
284 directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
285 let refreshed =
286 directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
287 let waiting = directory::pending_confirmations(&conn)
288 .map_err(|error| RouteError::internal(error.to_string()))?;
289 let confirmations_refreshed = directory::pending_confirmations_refreshed_at(&conn)
290 .map_err(|error| RouteError::internal(error.to_string()))?;
291 let preview =
292 directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
293
294 let mut groups = Vec::with_capacity(known.len());
295 for group in &known {
296 // Only for a group this user administers: the server refuses a
297 // non-admin the member list, so for any other group there are no rows
298 // and that is correct rather than missing. Read from `is_admin` rather
299 // than from the list being empty, because those are different facts and
300 // only one is about permission.
301 let (members, invitations) = if group.is_admin {
302 let members = directory::members(&conn, group.id)
303 .map_err(|error| RouteError::internal(error.to_string()))?;
304 let invitations = directory::invitations(&conn, group.id)
305 .map_err(|error| RouteError::internal(error.to_string()))?;
306 (members, invitations)
307 } else {
308 (Vec::new(), Vec::new())
309 };
310
311 groups.push(Group {
312 name: group.name.clone(),
313 is_admin: group.is_admin,
314 members: members
315 .into_iter()
316 .map(|member| Member {
317 is_admin: member.role == "admin",
318 email: member.email,
319 })
320 .collect(),
321 invitations: invitations
322 .into_iter()
323 .filter_map(|invitation| {
324 let state = invitation.effective_state();
325 // Drawn at the top of the pane instead. An accepted
326 // invitation is the one state where a person is waiting,
327 // and finding it by opening each group in turn is how
328 // somebody waits a week.
329 if state == InvitationState::Accepted {
330 return None;
331 }
332 let usable = state == InvitationState::Pending;
333 Some(Invitation {
334 group: group.id.to_string(),
335 id: invitation.id.to_string(),
336 state: state_word(state),
337 usable,
338 issued: invitation.created_at,
339 expires: invitation.expires_at,
340 token: usable.then_some(invitation.token).flatten(),
341 })
342 })
343 .collect(),
344 });
345 }
346 drop(conn);
347
348 let queued = crate::group_queue::pending(app).map_err(RouteError::internal)?;
349 // What a queued confirm is authorizing, read now rather than copied when it
350 // was queued. Read once for the whole list: a person with three confirms
351 // queued should not cost three passes over the same table.
352 let fingerprint_of = |invitation: Option<&str>| -> Option<String> {
353 let wanted = invitation?;
354 waiting
355 .iter()
356 .find(|invitation| invitation.id.to_string() == wanted)?
357 .invitee_fingerprint
358 .clone()
359 };
360
361 Ok(Sharing {
362 confirmations: waiting
363 .iter()
364 .map(|invitation| Confirmation {
365 group: invitation.group_id.to_string(),
366 id: invitation.id.to_string(),
367 who: invitation
368 .invitee_email
369 .clone()
370 .unwrap_or_else(|| "Somebody".to_owned()),
371 fingerprint: invitation.invitee_fingerprint.clone(),
372 })
373 .collect(),
374 confirmations_refreshed,
375 queued: queued
376 .into_iter()
377 .map(|op| Queued {
378 doing: op
379 .describe_confirming(fingerprint_of(op.invitation_id.as_deref()).as_deref()),
380 done: op.done_at.is_some(),
381 failed: op
382 .last_error
383 .map(|error| format!("{error} Tried {} times so far.", op.attempts)),
384 id: op.id,
385 })
386 .collect(),
387 configured: app.read_recovering().is_some(),
388 administered: known
389 .iter()
390 .filter(|group| group.is_admin)
391 .map(|group| Choice::new(group.id.to_string(), &group.name))
392 .collect(),
393 held: preview.map(|preview| Held {
394 refused: format!(
395 "That code leads to {} and cannot be used: {}.",
396 preview.group_name,
397 state_word(preview.state).to_lowercase()
398 ),
399 group: preview.group_name,
400 inviter: preview.inviter_email,
401 expires: preview.expires_at,
402 usable: preview.redeemable,
403 }),
404 identity: identity(app),
405 groups,
406 refreshed,
407 })
408 }
409
410 /// This device's identity key, and the fingerprint to read it out by.
411 ///
412 /// Both are local: `my_identity_public_key` derives from the master key and
413 /// `fingerprint_of_base64` hashes it. The Tauri commands wrapping them are
414 /// `async` because their siblings are, not because either awaits.
415 fn identity(app: &AppState) -> Identity {
416 let Some(client) = app.read_recovering() else {
417 return Identity::Unconfigured;
418 };
419 let Ok(pubkey) = client.my_identity_public_key() else {
420 return Identity::Locked;
421 };
422 Identity::Held {
423 fingerprint: synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey)
424 .unwrap_or_else(|_| "unavailable".to_owned()),
425 pubkey,
426 }
427 }
428
429 declare! {
430 /// One member of an administered group.
431 shape member_row(member: &Member) -> Row;
432
433 row &member.email {
434 token Tag::badge("Admin") when member.is_admin;
435 }
436 }
437
438 declare! {
439 /// One invitation, below its group's member list.
440 ///
441 /// The primary says what the row *is* and the state goes in a badge, the
442 /// way `Admin` already does on a member: these share a list with the member
443 /// rows above them, and a row led by "Waiting to be used" beside three
444 /// email addresses reads as a fourth person.
445 ///
446 /// Only a pending invitation carries a deadline and a Cancel. Everything
447 /// else is terminal, so no act: the directory prunes those once the server
448 /// stops reporting them, and `Redeemed` reads as history because by then
449 /// they are in the member list above.
450 shape invitation_row(invitation: &Invitation) -> Row;
451
452 row "Invite code" {
453 token Tag::badge(invitation.state);
454 meta "Issued {invitation.issued}";
455
456 secondary "Stops working {invitation.expires}." when invitation.usable;
457
458 act "Cancel it"
459 to post "/settings/sharing/invites/{invitation.group}/{invitation.id}/revoke"
460 when invitation.usable {
461 tone Danger;
462 }
463 }
464 }
465
466 declare! {
467 /// The code itself, on its own row.
468 ///
469 /// A field rather than text so it is selectable everywhere and so a host
470 /// with a copy affordance has something to attach it to. Nothing in the
471 /// vocabulary names "copy this value and say so briefly" (quasicoherent
472 /// `c3e145e0`), which is the same answer, for the same reason, as the
473 /// identity key below.
474 shape invitation_token(invitation: &Invitation, token: &str) -> Row;
475
476 row "The code to hand over" {
477 beside Secondary include token_field(invitation, token);
478 }
479 }
480
481 declare! {
482 /// The code itself.
483 shape token_field(invitation: &Invitation, token: &str) -> Field;
484
485 field Text "invite_token_{invitation.id}" "Invite code" {
486 value token;
487 hint "One use. Whoever redeems it still lands in your confirmations.";
488 }
489 }
490
491 declare! {
492 /// One group, with its members and its invitations under it.
493 shape group_rows(group: &Group) -> Vec<Node>;
494
495 list {
496 row &group.name {
497 token Tag::badge("You administer this") when group.is_admin;
498 }
499
500 row "No members recorded yet" when group.is_admin and group.members.is_empty() {
501 secondary "The member list is fetched on a sync; this device has not had one back.";
502 }
503
504 for member in group.members.iter() {
505 include member_row(member);
506 }
507
508 for invitation in group.invitations.iter() {
509 include invitation_row(invitation);
510
511 for token in invitation.token.iter() {
512 include invitation_token(invitation, token);
513 }
514 }
515 }
516 }
517
518 declare! {
519 /// One invitation waiting on this admin.
520 ///
521 /// The fingerprint is on the row's face rather than behind a menu, because
522 /// it is the thing being decided rather than a detail about the decision.
523 shape confirmation_row(confirmation: &Confirmation) -> Row;
524
525 row &confirmation.who {
526 token Tag::badge("Cannot be checked").tone(Tone::Danger)
527 unless confirmation.fingerprint is_not None;
528
529 secondary "The key on this invitation does not read as a key, so there is no \
530 fingerprint to compare. Cancel it from the group and issue another."
531 unless confirmation.fingerprint is_not None;
532
533 for fingerprint in confirmation.fingerprint.iter() {
534 secondary "Fingerprint {fingerprint}. Ask them to read it out over a channel \
535 that is not this one, and admit them only if it matches.";
536
537 act "It matches, admit them"
538 to post "/settings/sharing/invites/{confirmation.group}/{confirmation.id}/confirm";
539
540 act "Reject"
541 to post "/settings/sharing/invites/{confirmation.group}/{confirmation.id}/revoke" {
542 tone Danger;
543 }
544 }
545 }
546 }
547
548 declare! {
549 /// Every invitation waiting on this admin, across every group they
550 /// administer.
551 ///
552 /// Above the group list on purpose. This is the security-bearing step of
553 /// the whole flow: the invitee has posted a key and nothing about them
554 /// reaches the group until somebody compares its fingerprint against what
555 /// they read out over another channel. It is also the only place a person
556 /// is actively blocked, and it earns the position for the same reason the
557 /// queue section already sits above the admin controls.
558 shape confirmations(sharing: &Sharing) -> Vec<Node>;
559
560 section "Waiting on you to admit them" unless sharing.confirmations.is_empty();
561
562 list {
563 for confirmation in sharing.confirmations.iter() {
564 include confirmation_row(confirmation);
565 }
566 } unless sharing.confirmations.is_empty();
567
568 for at in sharing.confirmations_refreshed.iter() {
569 text "This list was last refreshed {at}. Somebody who accepted since then is not \
570 here yet."
571 unless sharing.confirmations.is_empty();
572 }
573 }
574
575 declare! {
576 /// One queued write.
577 shape queued_row(op: &Queued) -> Row;
578
579 row &op.doing {
580 token Tag::badge("Done").tone(Tone::Success) when op.done;
581 token Tag::badge("Waiting") when not op.done and op.failed is None;
582
583 for failed in op.failed.iter() {
584 token Tag::badge("Failed").tone(Tone::Danger) unless op.done;
585 secondary failed unless op.done;
586 }
587
588 // Held back rather than on the row's face: cancelling a queued write is
589 // a correction, and the row's job is saying what is about to happen.
590 menu [Act::new("Cancel", Action::post("/settings/sharing/queue/{op.id}/cancel"))
591 .tone(Tone::Danger)]
592 unless op.done;
593 }
594 }
595
596 declare! {
597 /// What is queued and has not landed yet.
598 ///
599 /// Shown rather than hidden: a control whose effect is a minute away has to
600 /// be visible, or the section looks like it lost the request. A failed row
601 /// carries the server's own words, which is the whole reason a queue beats
602 /// a toast here.
603 shape queue(sharing: &Sharing) -> Vec<Node>;
604
605 section "Waiting to reach the server" unless sharing.queued.is_empty();
606
607 list {
608 for op in sharing.queued.iter() {
609 include queued_row(op);
610 }
611 } unless sharing.queued.is_empty();
612 }
613
614 declare! {
615 /// The controls that queue an admin write.
616 ///
617 /// Offered only where they can mean something. Creating a group needs sync
618 /// configured; adding and inviting need a group this user administers. A
619 /// control drawn where it cannot act is the thing this section spent a
620 /// commit not doing.
621 shape admin_acts(sharing: &Sharing) -> Vec<Node>;
622
623 section "Make a group" when sharing.configured;
624
625 form post "/settings/sharing/groups" when sharing.configured {
626 submit "Queue it";
627
628 field Text "name" "Group name" {
629 required;
630 hint "It is created within a minute, and waits if you are offline.";
631 }
632 }
633
634 section "Admit somebody" when sharing.configured and not sharing.administered.is_empty();
635
636 text "Ask them for the public key their own Sharing section shows, and check its \
637 fingerprint with them out of band. The key admits them to nothing until you \
638 seal the group key to it, which is what this does."
639 when sharing.configured and not sharing.administered.is_empty();
640
641 form post "/settings/sharing/members"
642 when sharing.configured and not sharing.administered.is_empty() {
643 submit "Queue it";
644
645 field Select "group_id" "Group" {
646 options sharing.administered.clone();
647 required;
648 }
649
650 field Email "email" "Their address" {
651 required;
652 }
653
654 field Text "pubkey" "Their public key" {
655 required;
656 hint "The long value from their Sharing section, pasted whole.";
657 }
658 }
659
660 // The other way in, and the one that needs nothing from them first. An
661 // invite code is not a bearer credential for membership: whoever redeems it
662 // lands in the confirmations section above and gets nothing until their
663 // fingerprint is checked, which is the same check the manual path makes
664 // before it is used rather than after.
665 section "Or hand out an invite code"
666 when sharing.configured and not sharing.administered.is_empty();
667
668 text "They paste the code into their own Sharing section. It admits them to nothing \
669 on its own: it puts them in front of you, with a fingerprint to check."
670 when sharing.configured and not sharing.administered.is_empty();
671
672 form post "/settings/sharing/invites"
673 when sharing.configured and not sharing.administered.is_empty() {
674 submit "Queue it";
675
676 field Select "group_id" "Group" {
677 options sharing.administered.clone();
678 required;
679 }
680
681 field Number "expires_in_hours" "Hours it stays usable" {
682 at_least "1";
683 hint "Left blank, the server's own default stands.";
684 }
685 }
686 }
687
688 declare! {
689 /// The half for somebody who has been handed a code.
690 ///
691 /// Not drawn without sync configured, for the admin controls' reason:
692 /// accepting posts this device's identity key, and there is no key until
693 /// sync is set up, so the control could not act.
694 ///
695 /// Two queued writes rather than one, and the hint says so. Previewing is
696 /// what lets a person read what they are joining before they join it, and
697 /// folding it into Accept would delete the step rather than speed it up.
698 shape join(sharing: &Sharing) -> Vec<Node>;
699
700 section "Joining a group you were invited to" when sharing.configured;
701
702 form post "/settings/sharing/invites/preview"
703 when sharing.configured and sharing.held.is_none() {
704 submit "Read it";
705
706 field Text "token" "Invite code" {
707 required;
708 hint "Reading the code and accepting it are two queued writes, so it can be \
709 two minutes before you are asked to accept. Nothing is sent until you do.";
710 }
711 }
712
713 for held in sharing.held.iter() {
714 extend held_invite(held) when sharing.configured;
715 }
716 }
717
718 declare! {
719 /// The invitation this device has read, and what can be done with it.
720 shape held_invite(held: &Held) -> Vec<Node>;
721
722 list {
723 row &held.group when held.usable {
724 secondary "Invited by {held.inviter}.";
725 meta "Usable until {held.expires}";
726 }
727 } when held.usable;
728
729 text "Accepting sends this device's public key. You are not in the group yet at that \
730 point: it appears once the group's admin has checked your fingerprint against \
731 what you read out to them."
732 when held.usable;
733
734 empty &held.refused unless held.usable;
735
736 list {
737 row "Accept it" when held.usable {
738 act "Accept" to post "/settings/sharing/invites/accept";
739 act "Dismiss" to post "/settings/sharing/invites/dismiss" {
740 tone Danger;
741 }
742 }
743
744 row "Nothing to accept" unless held.usable {
745 act "Dismiss" to post "/settings/sharing/invites/dismiss";
746 }
747 }
748 }
749
750 declare! {
751 /// Your identity public key, and the fingerprint to read it out by.
752 ///
753 /// Each is a field rather than text so a host that can offer a copy control
754 /// has something to attach it to, and so the value is selectable everywhere
755 /// else. Nothing in the vocabulary names "copy this value and say so
756 /// briefly" (quasicoherent `c3e145e0`), which is the same answer, for the
757 /// same reason, as the invite code above.
758 shape identity_section(identity: &Identity) -> Vec<Node>;
759
760 section "Your identity key";
761
762 given identity {
763 Identity::Unconfigured -> empty "Sync is not set up on this device, so there is \
764 no identity key yet. A key is derived from your \
765 encryption master key.";
766 Identity::Locked -> empty "Encryption is not set up yet, so there is no identity \
767 key to show.";
768 otherwise -> text "Give this to a group's admin and they can add you. It is a \
769 public key: it admits you to nothing on its own.";
770 }
771
772 for pubkey in identity.pubkey().into_iter() {
773 include pubkey_field(pubkey);
774 }
775
776 for fingerprint in identity.fingerprint().into_iter() {
777 include fingerprint_field(fingerprint);
778 }
779 }
780
781 declare! {
782 /// The key itself.
783 shape pubkey_field(pubkey: &str) -> Field;
784
785 field Text "identity_pubkey" "Your public key" {
786 value pubkey;
787 hint "Read the fingerprint below out loud to check it arrived intact.";
788 }
789 }
790
791 declare! {
792 /// The fingerprint to read it out by.
793 shape fingerprint_field(fingerprint: &str) -> Field;
794
795 field Text "identity_fingerprint" "Fingerprint" {
796 value fingerprint;
797 }
798 }
799
800 impl Identity {
801 /// The key, when there is one. Total, because a hole is evaluated whether
802 /// or not the member it feeds is placed.
803 fn pubkey(&self) -> Option<&str> {
804 match self {
805 Self::Held { pubkey, .. } => Some(pubkey),
806 _ => None,
807 }
808 }
809
810 /// Its fingerprint, on the same terms.
811 fn fingerprint(&self) -> Option<&str> {
812 match self {
813 Self::Held { fingerprint, .. } => Some(fingerprint),
814 _ => None,
815 }
816 }
817 }
818
819 declare! {
820 /// The Sharing pane.
821 pub(super) shape sharing_pane(sharing: &Sharing) -> Vec<Node>;
822
823 section "Sharing";
824
825 // Above everything, including the group list. Somebody is waiting.
826 extend confirmations(sharing);
827
828 empty "This device knows of no groups. A group list arrives with a sync, so a device \
829 that has not synced since you joined one will not show it yet."
830 when sharing.groups.is_empty();
831
832 for group in sharing.groups.iter() {
833 extend group_rows(group);
834 }
835
836 for at in sharing.refreshed.iter() {
837 text "Group list last updated {at}." unless sharing.groups.is_empty();
838 }
839
840 extend queue(sharing);
841 extend admin_acts(sharing);
842 extend join(sharing);
843 extend identity_section(&sharing.identity);
844 }
845
846 /// Queue a new group.
847 ///
848 /// The whole of what the handler does is a local insert, which is the point: the
849 /// conversation with the server happens in [`crate::group_queue`], a minute
850 /// later, where there is a runtime.
851 pub(super) fn create_group(
852 app: &AppState,
853 request: &quasi_router::Request,
854 ) -> Result<&'static str, RouteError> {
855 let name = request
856 .payload
857 .get("name")
858 .unwrap_or_default()
859 .trim()
860 .to_owned();
861 if name.is_empty() {
862 return Err(RouteError::conflict("A group needs a name."));
863 }
864
865 crate::group_queue::enqueue(
866 app,
867 &crate::group_queue::QueuedOp {
868 id: uuid::Uuid::new_v4().to_string(),
869 kind: "create_group".to_owned(),
870 group_id: None,
871 name: Some(name),
872 email: None,
873 pubkey: None,
874 member_user_id: None,
875 ..Default::default()
876 },
877 )
878 .map_err(RouteError::internal)?;
879
880 Ok("Queued. The group is created within a minute, or when you are next online.")
881 }
882
883 /// Queue an add-member.
884 ///
885 /// The three values are checked for shape and not for truth: whether the key is
886 /// really theirs is a question only the fingerprint they read out can answer, and
887 /// whether the server accepts it is the drainer's to report.
888 pub(super) fn add_member(
889 app: &AppState,
890 request: &quasi_router::Request,
891 ) -> Result<&'static str, RouteError> {
892 let field = |name: &str| {
893 request
894 .payload
895 .get(name)
896 .unwrap_or_default()
897 .trim()
898 .to_owned()
899 };
900 let (group_id, email, pubkey) = (field("group_id"), field("email"), field("pubkey"));
901
902 if group_id.is_empty() || email.is_empty() || pubkey.is_empty() {
903 return Err(RouteError::conflict(
904 "A group, an address and a public key are all needed.",
905 ));
906 }
907 // Refused here rather than queued to fail later: a key that is not a key can
908 // never be sealed to, and the person who pasted it is on screen now.
909 if synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey).is_err() {
910 return Err(RouteError::conflict(
911 "That does not look like a public key.",
912 ));
913 }
914
915 crate::group_queue::enqueue(
916 app,
917 &crate::group_queue::QueuedOp {
918 id: uuid::Uuid::new_v4().to_string(),
919 kind: "add_member".to_owned(),
920 group_id: Some(group_id),
921 name: None,
922 email: Some(email),
923 pubkey: Some(pubkey),
924 member_user_id: None,
925 ..Default::default()
926 },
927 )
928 .map_err(RouteError::internal)?;
929
930 Ok("Queued. They are admitted within a minute, once your encryption key is loaded.")
931 }
932
933 /// Queue a fresh invite code for a group.
934 ///
935 /// The expiry is the one value with a shape to check, and blank is a real
936 /// answer rather than a mistake: the server has a default and saying nothing
937 /// takes it.
938 pub(super) fn create_invite(
939 app: &AppState,
940 request: &quasi_router::Request,
941 ) -> Result<&'static str, RouteError> {
942 let group_id = request
943 .payload
944 .get("group_id")
945 .unwrap_or_default()
946 .trim()
947 .to_owned();
948 if group_id.is_empty() {
949 return Err(RouteError::conflict("An invite needs a group."));
950 }
951
952 let raw = request
953 .payload
954 .get("expires_in_hours")
955 .unwrap_or_default()
956 .trim()
957 .to_owned();
958 let expires_in_hours = if raw.is_empty() {
959 None
960 } else {
961 Some(
962 raw.parse::<i64>()
963 .ok()
964 .filter(|hours| *hours > 0)
965 .ok_or_else(|| {
966 RouteError::conflict("Hours has to be a whole number above zero, or blank.")
967 })?,
968 )
969 };
970
971 crate::group_queue::enqueue(
972 app,
973 &crate::group_queue::QueuedOp {
974 id: uuid::Uuid::new_v4().to_string(),
975 kind: "create_invite".to_owned(),
976 group_id: Some(group_id),
977 expires_in_hours,
978 ..Default::default()
979 },
980 )
981 .map_err(RouteError::internal)?;
982
983 Ok("Queued. The code appears under the group within a minute.")
984 }
985
986 /// Queue a revoke, from either the group's list or the confirmations section.
987 ///
988 /// One route for both because it is one act: cancelling an invitation is the
989 /// same server call whether nobody has used it yet or somebody has and their
990 /// fingerprint did not match.
991 pub(super) fn revoke_invite(
992 app: &AppState,
993 request: &quasi_router::Request,
994 ) -> Result<&'static str, RouteError> {
995 let (group_id, invitation_id) = addressed(request)?;
996 crate::group_queue::enqueue(
997 app,
998 &crate::group_queue::QueuedOp {
999 id: uuid::Uuid::new_v4().to_string(),
1000 kind: "revoke_invite".to_owned(),
1001 group_id: Some(group_id),
1002 invitation_id: Some(invitation_id),
1003 ..Default::default()
1004 },
1005 )
1006 .map_err(RouteError::internal)?;
1007
1008 Ok("Queued. The invitation is cancelled within a minute.")
1009 }
1010
1011 /// Queue a confirm: admit the holder of the key on this invitation.
1012 ///
1013 /// Carries the group and the invitation and nothing else. The fingerprint the
1014 /// admin just read is deliberately not sent: the drainer asks the server what
1015 /// key it is holding now, so a key swapped between the reading and the drain is
1016 /// refused by the same comparison rather than waved through by a copy of it.
1017 pub(super) fn confirm_invite(
1018 app: &AppState,
1019 request: &quasi_router::Request,
1020 ) -> Result<&'static str, RouteError> {
1021 let (group_id, invitation_id) = addressed(request)?;
1022 crate::group_queue::enqueue(
1023 app,
1024 &crate::group_queue::QueuedOp {
1025 id: uuid::Uuid::new_v4().to_string(),
1026 kind: "confirm_invite".to_owned(),
1027 group_id: Some(group_id),
1028 invitation_id: Some(invitation_id),
1029 ..Default::default()
1030 },
1031 )
1032 .map_err(RouteError::internal)?;
1033
1034 Ok("Queued. They are admitted within a minute, once your encryption key is loaded.")
1035 }
1036
1037 /// The group and invitation a row's act names, both from the path.
1038 fn addressed(request: &quasi_router::Request) -> Result<(String, String), RouteError> {
1039 let capture = |name: &str| {
1040 request
1041 .captures
1042 .get(name)
1043 .filter(|value| !value.is_empty())
1044 .map(ToOwned::to_owned)
1045 .ok_or_else(|| RouteError::not_found("no such invitation"))
1046 };
1047 Ok((capture("group_id")?, capture("invitation_id")?))
1048 }
1049
1050 /// Queue a read of what a pasted code leads to.
1051 ///
1052 /// Normalised here rather than in the drainer, so the one accepted spelling of a
1053 /// code lives in [`normalize_invite_token`] and a queued row already holds the
1054 /// value the server will be asked about.
1055 pub(super) fn preview_invite(
1056 app: &AppState,
1057 request: &quasi_router::Request,
1058 ) -> Result<&'static str, RouteError> {
1059 let token = normalize_invite_token(request.payload.get("token").unwrap_or_default());
1060 if token.is_empty() {
1061 return Err(RouteError::conflict("Paste the code you were given."));
1062 }
1063
1064 crate::group_queue::enqueue(
1065 app,
1066 &crate::group_queue::QueuedOp {
1067 id: uuid::Uuid::new_v4().to_string(),
1068 kind: "preview_invite".to_owned(),
1069 invite_token: Some(token),
1070 ..Default::default()
1071 },
1072 )
1073 .map_err(RouteError::internal)?;
1074
1075 Ok("Queued. What the code leads to appears here within a minute, before anything is sent.")
1076 }
1077
1078 /// Queue an accept of the code this device is holding an answer about.
1079 ///
1080 /// Reads the token from the stored preview rather than from the request: the
1081 /// Accept control appears only because a preview is on screen, and re-sending
1082 /// the code through the form would let the two disagree.
1083 pub(super) fn accept_invite(app: &AppState) -> Result<&'static str, RouteError> {
1084 let conn = conn(app)?;
1085 let held =
1086 directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
1087 drop(conn);
1088
1089 let preview = held.ok_or_else(|| {
1090 RouteError::not_found("There is no invite code waiting to be accepted here.")
1091 })?;
1092 // Terminal states are drawn without an Accept control, so reaching this is
1093 // a stale screen rather than a misuse. Refused rather than queued: it can
1094 // only fail, and failing here says so now instead of in a minute.
1095 if !preview.redeemable {
1096 return Err(RouteError::conflict(
1097 "That code cannot be used any more. Dismiss it and ask for another.",
1098 ));
1099 }
1100
1101 crate::group_queue::enqueue(
1102 app,
1103 &crate::group_queue::QueuedOp {
1104 id: uuid::Uuid::new_v4().to_string(),
1105 kind: "accept_invite".to_owned(),
1106 invite_token: Some(preview.token),
1107 ..Default::default()
1108 },
1109 )
1110 .map_err(RouteError::internal)?;
1111
1112 Ok("Queued. Your key is sent within a minute; the group appears once its admin confirms it.")
1113 }
1114
1115 /// Forget the previewed code.
1116 ///
1117 /// A local delete and not a queued write, because there is nothing to tell a
1118 /// server: a preview was a read, and dropping the answer to it is this device's
1119 /// business alone.
1120 pub(super) fn dismiss_preview(app: &AppState) -> Result<&'static str, RouteError> {
1121 let conn = conn(app)?;
1122 directory::clear_preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
1123 Ok("Forgotten.")
1124 }
1125
1126 /// Take a queued write back out.
1127 pub(super) fn cancel(
1128 app: &AppState,
1129 request: &quasi_router::Request,
1130 ) -> Result<&'static str, RouteError> {
1131 let id = request
1132 .captures
1133 .get("id")
1134 .ok_or_else(|| RouteError::not_found("no queued action"))?;
1135 if crate::group_queue::cancel(app, id).map_err(RouteError::internal)? {
1136 Ok("Taken back out of the queue.")
1137 } else {
1138 // Either it never existed or the drainer got to it first, and the second
1139 // is the interesting one: a cancel that raced a success must not report
1140 // that it undid anything, because it did not.
1141 Err(RouteError::not_found(
1142 "That action is not in the queue any more.",
1143 ))
1144 }
1145 }
1146