//! Groups: who you share with, and the key an admin needs to add you. //! //! //! //! # The reads are local //! //! synckit writes the group directory down: `sync_groups` and //! `sync_group_members` keep what the sync loop asks the server for, and //! `synckit_client::store::directory` reads it synchronously. The remote read //! happens on a schedule, outside the request loop, which is where a //! description can live with it. So this section states how old its answers are //! rather than pretending they are live. //! //! # What is here //! //! The groups this device knows you belong to, which of them you administer, //! who is in those, and your identity public key with its fingerprint. //! //! **The whole member side of the flow is here.** Being added to a group takes //! no write from the person being added: they show their public key, an admin //! seals the group key to it. Both facts a member needs are local crypto rather //! than a request; `my_group_pubkey` and `my_group_fingerprint` are `async` by //! declaration only, and neither body awaits. //! //! # The admin writes are queued rather than performed //! //! Creating a group, adding a member and removing one each open a conversation //! with a server, and a handler cannot await. So the description says "queue //! this" and [`crate::group_queue`] drains it a minute later. //! //! What that buys, beyond making the controls sayable at all: the write //! survives being offline, a failure is a row with the server's reason on it //! sitting in this section rather than a toast that has gone, and `add_member` //! can be asked for before the master key is loaded, because the queue turns //! "not right now" into "once you unlock". //! //! The queue is shown. A control whose effect is a minute away has to be, or //! the section looks like it lost the request. //! //! # The invitation flow, and where its one secret lives //! //! Six commands and a state machine rather than one call. Five of the six queue //! exactly as the three above do; the sixth, listing, is a directory read. //! //! **The token is not on the queue row, and that is the load-bearing part.** //! `create_invitation` returns a code the server keeps only a hash of, so what //! comes back is the only copy that will ever exist. A queue row is swept an //! hour after it lands, so putting the code there would destroy it on a timer; //! the drainer writes it into `sync_invitations` through //! [`directory::record_issued`] the instant it arrives, and the section reads it //! from there. //! //! The mirror of it: a code the *user* pasted is on the queue row //! (`invite_token`), because nothing on this device is the authority on it and //! the drainer has to read it a minute later. Two things called a token, one //! written down here and one never. //! //! # Confirming is the security of the flow, so it sits at the top //! //! An accepted invitation is a person waiting on a fingerprint comparison, and //! nothing about them reaches the group until an admin makes it. That is the //! only step here where somebody is actively blocked and the only one where //! getting it wrong admits the wrong key, so it is drawn above the group list //! rather than found by opening each group, and the fingerprint is on the row's //! face rather than behind a menu. //! //! A queued confirm is drawn with the fingerprint too, resolved from the //! directory as the row is drawn. That is a display of the server's current //! answer; the queue row itself carries only the invitation's id, so the //! drainer re-reads rather than acting on a value copied a minute ago. //! //! # Two drains, and the section says so //! //! Previewing a code and accepting it are separate queued writes, so a paste //! takes up to two minutes to become a membership request. Collapsing them into //! one op would remove the step where a person reads what they are joining //! before they join it, which is the point of a preview. The hint says the wait //! out loud instead. //! //! # What is not here //! //! A copy control on a pending code. Nothing in the vocabulary names "copy this //! value and say so briefly" (quasicoherent `c3e145e0`), so the token is drawn //! as a field, which is selectable everywhere and is something a host with a //! copy affordance can attach it to. Same answer, same reason, as the identity //! key below. use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Act, Choice, Tag}; use quasi_router::{Action, RouteError}; use synckit_client::InvitationState; use synckit_client::store::directory; use crate::commands::group::normalize_invite_token; use crate::state::AppState; /// Read a connection out of the pool, or say so in the class that means it. /// /// Returned behind [`DerefMut`](std::ops::DerefMut) rather than by its real /// type, so this module does not have to name r2d2's pooled-connection generic /// to say "a connection". fn conn( app: &AppState, ) -> Result, RouteError> { app.db .conn() .map_err(|error| RouteError::internal(error.to_string())) } /// The word for a state, and there is no catch-all. /// /// `InvitationState` is `#[non_exhaustive]`, so a variant a newer server knows /// about arrives here as something this build cannot name. Saying so beats /// picking the nearest word: every state below decides whether acts are /// offered, and guessing wrong offers an act on an invitation that cannot take /// it. fn state_word(state: InvitationState) -> &'static str { match state { InvitationState::Pending => "Waiting to be used", InvitationState::Accepted => "Waiting on you", InvitationState::Redeemed => "Used", InvitationState::Revoked => "Cancelled", InvitationState::Expired => "Expired", _ => "In a state this version does not know", } } /// One member of a group this user administers. struct Member { /// Their account email, which is what the row is called. email: String, /// Whether they administer it too. is_admin: bool, } /// One invitation to a group this user administers, minus the accepted ones: /// those are drawn at the top of the pane instead. /// /// Every field is read off /// [`effective_state`](directory::KnownInvitation::effective_state) rather than /// the stored `state`: the directory refreshes on a cycle and a deadline passes /// on its own, so between two refreshes the stored value says `Pending` for a /// code that has stopped working. Offering that as a live code is the one /// failure this section can produce without anybody doing anything. struct Invitation { /// The group it admits somebody to, which the Cancel address names. group: String, /// The invitation, which the Cancel address names and the code field is /// keyed by. id: String, /// What the badge says about where it has got to. state: &'static str, /// Whether it can still be used, which is the only state with a code, a /// deadline and a Cancel. usable: bool, /// When it was issued. issued: String, /// When it stops working. expires: String, /// The one-use code, held only while pending and only on the device that /// issued it. `write_invitations` drops the column at the same moment the /// screen stops drawing it, so this is the screen agreeing with the store /// rather than a second rule. token: Option, } /// One group this device knows the user belongs to. struct Group { /// What it is called. name: String, /// Whether this user administers it, which is what decides whether the /// server will hand over a member list at all. is_admin: bool, /// Its members. Empty means the fetch has not landed, which is a different /// fact from having none, and the pane says so in words. members: Vec, /// Its invitations, once it is administered. invitations: Vec, } /// One invitation waiting on this admin to compare a fingerprint. struct Confirmation { /// The group, which the two addresses name. group: String, /// The invitation, which the two addresses name. id: String, /// Who accepted, or "Somebody" when the server did not say. who: String, /// The fingerprint to read out, if the key yielded one. /// /// Absent must never be confirmable: the comparison is the whole security /// of the step, and a row that offers Confirm beside "unavailable" invites /// approving something nobody read. fingerprint: Option, } /// One queued write that has not landed yet. struct Queued { /// The op, which Cancel names. id: String, /// What it will do, with the fingerprint a queued confirm authorizes /// resolved as the row is drawn rather than copied when it was queued. doing: String, /// Whether the drainer has finished with it. done: bool, /// The server's own words, when it refused. A queue that reported "failed" /// and kept the reason would be worse than the toast it replaced. failed: Option, } /// This device's identity key, or why there is none. enum Identity { /// Sync is not set up, so no key has been derived. Unconfigured, /// Set up, with no master key loaded yet. Locked, /// The public key, and the fingerprint to read it out by. Held { /// The key itself, shown in a field so it is selectable and so a host /// with a copy affordance has something to attach one to. pubkey: String, /// Its fingerprint, or "unavailable" when it would not hash. fingerprint: String, }, } /// The invitation this device has previewed, if it has one. struct Held { /// The group it leads to. group: String, /// Who issued it. inviter: String, /// When it stops working. expires: String, /// Whether it can still be accepted. usable: bool, /// Why not, when it cannot. `redeemable` is false for every terminal state /// alike, so which one it is has to be read off the state: "that code has /// expired" and "that code was cancelled" send a person to different /// places. refused: String, } /// Everything the Sharing pane draws, read once. pub(super) struct Sharing { /// The groups this device knows about. groups: Vec, /// How old that list is, if it has ever arrived. Staleness stated rather /// than hidden: the list is a copy of the server's answer, and how old the /// copy is decides how much to trust it. refreshed: Option, /// Invitations waiting on this admin, across every group they administer. confirmations: Vec, /// How old *that* list is, which is not the same number. /// /// `pending_confirmations_refreshed_at` is the freshness of exactly the /// rows `pending_confirmations` returned, and it reports the stalest group /// it drew from: `write_invitations` is scoped per group so one group's /// failed fetch does not blank the others, which is also the cycle where a /// number about the group list would overstate this one. A section whose /// whole job is a security decision should not do that. confirmations_refreshed: Option, /// Writes queued and not landed. queued: Vec, /// Whether sync is set up at all, which is what every write here needs. configured: bool, /// The groups this user administers, as the two pickers offer them. administered: Vec, /// The invite code this device has read, if it has read one. held: Option, /// This device's identity key. identity: Identity, } /// The directory, read through the app's own pool. /// /// An empty answer is "this device knows of no groups", which is a true /// statement about what has reached it rather than a claim that the user /// belongs to none. The pane says so in those words. pub(super) fn read(app: &AppState) -> Result { let conn = conn(app)?; let known = directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?; let refreshed = directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?; let waiting = directory::pending_confirmations(&conn) .map_err(|error| RouteError::internal(error.to_string()))?; let confirmations_refreshed = directory::pending_confirmations_refreshed_at(&conn) .map_err(|error| RouteError::internal(error.to_string()))?; let preview = directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?; let mut groups = Vec::with_capacity(known.len()); for group in &known { // Only for a group this user administers: the server refuses a // non-admin the member list, so for any other group there are no rows // and that is correct rather than missing. Read from `is_admin` rather // than from the list being empty, because those are different facts and // only one is about permission. let (members, invitations) = if group.is_admin { let members = directory::members(&conn, group.id) .map_err(|error| RouteError::internal(error.to_string()))?; let invitations = directory::invitations(&conn, group.id) .map_err(|error| RouteError::internal(error.to_string()))?; (members, invitations) } else { (Vec::new(), Vec::new()) }; groups.push(Group { name: group.name.clone(), is_admin: group.is_admin, members: members .into_iter() .map(|member| Member { is_admin: member.role == "admin", email: member.email, }) .collect(), invitations: invitations .into_iter() .filter_map(|invitation| { let state = invitation.effective_state(); // Drawn at the top of the pane instead. An accepted // invitation is the one state where a person is waiting, // and finding it by opening each group in turn is how // somebody waits a week. if state == InvitationState::Accepted { return None; } let usable = state == InvitationState::Pending; Some(Invitation { group: group.id.to_string(), id: invitation.id.to_string(), state: state_word(state), usable, issued: invitation.created_at, expires: invitation.expires_at, token: usable.then_some(invitation.token).flatten(), }) }) .collect(), }); } drop(conn); let queued = crate::group_queue::pending(app).map_err(RouteError::internal)?; // What a queued confirm is authorizing, read now rather than copied when it // was queued. Read once for the whole list: a person with three confirms // queued should not cost three passes over the same table. let fingerprint_of = |invitation: Option<&str>| -> Option { let wanted = invitation?; waiting .iter() .find(|invitation| invitation.id.to_string() == wanted)? .invitee_fingerprint .clone() }; Ok(Sharing { confirmations: waiting .iter() .map(|invitation| Confirmation { group: invitation.group_id.to_string(), id: invitation.id.to_string(), who: invitation .invitee_email .clone() .unwrap_or_else(|| "Somebody".to_owned()), fingerprint: invitation.invitee_fingerprint.clone(), }) .collect(), confirmations_refreshed, queued: queued .into_iter() .map(|op| Queued { doing: op .describe_confirming(fingerprint_of(op.invitation_id.as_deref()).as_deref()), done: op.done_at.is_some(), failed: op .last_error .map(|error| format!("{error} Tried {} times so far.", op.attempts)), id: op.id, }) .collect(), configured: app.read_recovering().is_some(), administered: known .iter() .filter(|group| group.is_admin) .map(|group| Choice::new(group.id.to_string(), &group.name)) .collect(), held: preview.map(|preview| Held { refused: format!( "That code leads to {} and cannot be used: {}.", preview.group_name, state_word(preview.state).to_lowercase() ), group: preview.group_name, inviter: preview.inviter_email, expires: preview.expires_at, usable: preview.redeemable, }), identity: identity(app), groups, refreshed, }) } /// This device's identity key, and the fingerprint to read it out by. /// /// Both are local: `my_identity_public_key` derives from the master key and /// `fingerprint_of_base64` hashes it. The Tauri commands wrapping them are /// `async` because their siblings are, not because either awaits. fn identity(app: &AppState) -> Identity { let Some(client) = app.read_recovering() else { return Identity::Unconfigured; }; let Ok(pubkey) = client.my_identity_public_key() else { return Identity::Locked; }; Identity::Held { fingerprint: synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey) .unwrap_or_else(|_| "unavailable".to_owned()), pubkey, } } declare! { /// One member of an administered group. shape member_row(member: &Member) -> Row; row &member.email { token Tag::badge("Admin") when member.is_admin; } } declare! { /// One invitation, below its group's member list. /// /// The primary says what the row *is* and the state goes in a badge, the /// way `Admin` already does on a member: these share a list with the member /// rows above them, and a row led by "Waiting to be used" beside three /// email addresses reads as a fourth person. /// /// Only a pending invitation carries a deadline and a Cancel. Everything /// else is terminal, so no act: the directory prunes those once the server /// stops reporting them, and `Redeemed` reads as history because by then /// they are in the member list above. shape invitation_row(invitation: &Invitation) -> Row; row "Invite code" { token Tag::badge(invitation.state); meta "Issued {invitation.issued}"; secondary "Stops working {invitation.expires}." when invitation.usable; act "Cancel it" to post "/settings/sharing/invites/{invitation.group}/{invitation.id}/revoke" when invitation.usable { tone Danger; } } } declare! { /// The code itself, on its own row. /// /// A field rather than text so it is selectable everywhere and so a host /// with a copy affordance has something to attach it to. Nothing in the /// vocabulary names "copy this value and say so briefly" (quasicoherent /// `c3e145e0`), which is the same answer, for the same reason, as the /// identity key below. shape invitation_token(invitation: &Invitation, token: &str) -> Row; row "The code to hand over" { beside Secondary include token_field(invitation, token); } } declare! { /// The code itself. shape token_field(invitation: &Invitation, token: &str) -> Field; field Text "invite_token_{invitation.id}" "Invite code" { value token; hint "One use. Whoever redeems it still lands in your confirmations."; } } declare! { /// One group, with its members and its invitations under it. shape group_rows(group: &Group) -> Vec; list { row &group.name { token Tag::badge("You administer this") when group.is_admin; } row "No members recorded yet" when group.is_admin and group.members.is_empty() { secondary "The member list is fetched on a sync; this device has not had one back."; } for member in group.members.iter() { include member_row(member); } for invitation in group.invitations.iter() { include invitation_row(invitation); for token in invitation.token.iter() { include invitation_token(invitation, token); } } } } declare! { /// One invitation waiting on this admin. /// /// The fingerprint is on the row's face rather than behind a menu, because /// it is the thing being decided rather than a detail about the decision. shape confirmation_row(confirmation: &Confirmation) -> Row; row &confirmation.who { token Tag::badge("Cannot be checked").tone(Tone::Danger) unless confirmation.fingerprint is_not None; secondary "The key on this invitation does not read as a key, so there is no \ fingerprint to compare. Cancel it from the group and issue another." unless confirmation.fingerprint is_not None; for fingerprint in confirmation.fingerprint.iter() { secondary "Fingerprint {fingerprint}. Ask them to read it out over a channel \ that is not this one, and admit them only if it matches."; act "It matches, admit them" to post "/settings/sharing/invites/{confirmation.group}/{confirmation.id}/confirm"; act "Reject" to post "/settings/sharing/invites/{confirmation.group}/{confirmation.id}/revoke" { tone Danger; } } } } declare! { /// Every invitation waiting on this admin, across every group they /// administer. /// /// Above the group list on purpose. This is the security-bearing step of /// the whole flow: the invitee has posted a key and nothing about them /// reaches the group until somebody compares its fingerprint against what /// they read out over another channel. It is also the only place a person /// is actively blocked, and it earns the position for the same reason the /// queue section already sits above the admin controls. shape confirmations(sharing: &Sharing) -> Vec; section "Waiting on you to admit them" unless sharing.confirmations.is_empty(); list { for confirmation in sharing.confirmations.iter() { include confirmation_row(confirmation); } } unless sharing.confirmations.is_empty(); for at in sharing.confirmations_refreshed.iter() { text "This list was last refreshed {at}. Somebody who accepted since then is not \ here yet." unless sharing.confirmations.is_empty(); } } declare! { /// One queued write. shape queued_row(op: &Queued) -> Row; row &op.doing { token Tag::badge("Done").tone(Tone::Success) when op.done; token Tag::badge("Waiting") when not op.done and op.failed is None; for failed in op.failed.iter() { token Tag::badge("Failed").tone(Tone::Danger) unless op.done; secondary failed unless op.done; } // Held back rather than on the row's face: cancelling a queued write is // a correction, and the row's job is saying what is about to happen. menu [Act::new("Cancel", Action::post("/settings/sharing/queue/{op.id}/cancel")) .tone(Tone::Danger)] unless op.done; } } declare! { /// What is queued and has not landed yet. /// /// Shown rather than hidden: a control whose effect is a minute away has to /// be visible, or the section looks like it lost the request. A failed row /// carries the server's own words, which is the whole reason a queue beats /// a toast here. shape queue(sharing: &Sharing) -> Vec; section "Waiting to reach the server" unless sharing.queued.is_empty(); list { for op in sharing.queued.iter() { include queued_row(op); } } unless sharing.queued.is_empty(); } declare! { /// The controls that queue an admin write. /// /// Offered only where they can mean something. Creating a group needs sync /// configured; adding and inviting need a group this user administers. A /// control drawn where it cannot act is the thing this section spent a /// commit not doing. shape admin_acts(sharing: &Sharing) -> Vec; section "Make a group" when sharing.configured; form post "/settings/sharing/groups" when sharing.configured { submit "Queue it"; field Text "name" "Group name" { required; hint "It is created within a minute, and waits if you are offline."; } } section "Admit somebody" when sharing.configured and not sharing.administered.is_empty(); text "Ask them for the public key their own Sharing section shows, and check its \ fingerprint with them out of band. The key admits them to nothing until you \ seal the group key to it, which is what this does." when sharing.configured and not sharing.administered.is_empty(); form post "/settings/sharing/members" when sharing.configured and not sharing.administered.is_empty() { submit "Queue it"; field Select "group_id" "Group" { options sharing.administered.clone(); required; } field Email "email" "Their address" { required; } field Text "pubkey" "Their public key" { required; hint "The long value from their Sharing section, pasted whole."; } } // The other way in, and the one that needs nothing from them first. An // invite code is not a bearer credential for membership: whoever redeems it // lands in the confirmations section above and gets nothing until their // fingerprint is checked, which is the same check the manual path makes // before it is used rather than after. section "Or hand out an invite code" when sharing.configured and not sharing.administered.is_empty(); text "They paste the code into their own Sharing section. It admits them to nothing \ on its own: it puts them in front of you, with a fingerprint to check." when sharing.configured and not sharing.administered.is_empty(); form post "/settings/sharing/invites" when sharing.configured and not sharing.administered.is_empty() { submit "Queue it"; field Select "group_id" "Group" { options sharing.administered.clone(); required; } field Number "expires_in_hours" "Hours it stays usable" { at_least "1"; hint "Left blank, the server's own default stands."; } } } declare! { /// The half for somebody who has been handed a code. /// /// Not drawn without sync configured, for the admin controls' reason: /// accepting posts this device's identity key, and there is no key until /// sync is set up, so the control could not act. /// /// Two queued writes rather than one, and the hint says so. Previewing is /// what lets a person read what they are joining before they join it, and /// folding it into Accept would delete the step rather than speed it up. shape join(sharing: &Sharing) -> Vec; section "Joining a group you were invited to" when sharing.configured; form post "/settings/sharing/invites/preview" when sharing.configured and sharing.held.is_none() { submit "Read it"; field Text "token" "Invite code" { required; hint "Reading the code and accepting it are two queued writes, so it can be \ two minutes before you are asked to accept. Nothing is sent until you do."; } } for held in sharing.held.iter() { extend held_invite(held) when sharing.configured; } } declare! { /// The invitation this device has read, and what can be done with it. shape held_invite(held: &Held) -> Vec; list { row &held.group when held.usable { secondary "Invited by {held.inviter}."; meta "Usable until {held.expires}"; } } when held.usable; text "Accepting sends this device's public key. You are not in the group yet at that \ point: it appears once the group's admin has checked your fingerprint against \ what you read out to them." when held.usable; empty &held.refused unless held.usable; list { row "Accept it" when held.usable { act "Accept" to post "/settings/sharing/invites/accept"; act "Dismiss" to post "/settings/sharing/invites/dismiss" { tone Danger; } } row "Nothing to accept" unless held.usable { act "Dismiss" to post "/settings/sharing/invites/dismiss"; } } } declare! { /// Your identity public key, and the fingerprint to read it out by. /// /// Each is a field rather than text so a host that can offer a copy control /// has something to attach it to, and so the value is selectable everywhere /// else. Nothing in the vocabulary names "copy this value and say so /// briefly" (quasicoherent `c3e145e0`), which is the same answer, for the /// same reason, as the invite code above. shape identity_section(identity: &Identity) -> Vec; section "Your identity key"; given identity { Identity::Unconfigured -> empty "Sync is not set up on this device, so there is \ no identity key yet. A key is derived from your \ encryption master key."; Identity::Locked -> empty "Encryption is not set up yet, so there is no identity \ key to show."; otherwise -> text "Give this to a group's admin and they can add you. It is a \ public key: it admits you to nothing on its own."; } for pubkey in identity.pubkey().into_iter() { include pubkey_field(pubkey); } for fingerprint in identity.fingerprint().into_iter() { include fingerprint_field(fingerprint); } } declare! { /// The key itself. shape pubkey_field(pubkey: &str) -> Field; field Text "identity_pubkey" "Your public key" { value pubkey; hint "Read the fingerprint below out loud to check it arrived intact."; } } declare! { /// The fingerprint to read it out by. shape fingerprint_field(fingerprint: &str) -> Field; field Text "identity_fingerprint" "Fingerprint" { value fingerprint; } } impl Identity { /// The key, when there is one. Total, because a hole is evaluated whether /// or not the member it feeds is placed. fn pubkey(&self) -> Option<&str> { match self { Self::Held { pubkey, .. } => Some(pubkey), _ => None, } } /// Its fingerprint, on the same terms. fn fingerprint(&self) -> Option<&str> { match self { Self::Held { fingerprint, .. } => Some(fingerprint), _ => None, } } } declare! { /// The Sharing pane. pub(super) shape sharing_pane(sharing: &Sharing) -> Vec; section "Sharing"; // Above everything, including the group list. Somebody is waiting. extend confirmations(sharing); empty "This device knows of no groups. A group list arrives with a sync, so a device \ that has not synced since you joined one will not show it yet." when sharing.groups.is_empty(); for group in sharing.groups.iter() { extend group_rows(group); } for at in sharing.refreshed.iter() { text "Group list last updated {at}." unless sharing.groups.is_empty(); } extend queue(sharing); extend admin_acts(sharing); extend join(sharing); extend identity_section(&sharing.identity); } /// Queue a new group. /// /// The whole of what the handler does is a local insert, which is the point: the /// conversation with the server happens in [`crate::group_queue`], a minute /// later, where there is a runtime. pub(super) fn create_group( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let name = request .payload .get("name") .unwrap_or_default() .trim() .to_owned(); if name.is_empty() { return Err(RouteError::conflict("A group needs a name.")); } crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "create_group".to_owned(), group_id: None, name: Some(name), email: None, pubkey: None, member_user_id: None, ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. The group is created within a minute, or when you are next online.") } /// Queue an add-member. /// /// The three values are checked for shape and not for truth: whether the key is /// really theirs is a question only the fingerprint they read out can answer, and /// whether the server accepts it is the drainer's to report. pub(super) fn add_member( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let (group_id, email, pubkey) = (field("group_id"), field("email"), field("pubkey")); if group_id.is_empty() || email.is_empty() || pubkey.is_empty() { return Err(RouteError::conflict( "A group, an address and a public key are all needed.", )); } // Refused here rather than queued to fail later: a key that is not a key can // never be sealed to, and the person who pasted it is on screen now. if synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey).is_err() { return Err(RouteError::conflict( "That does not look like a public key.", )); } crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "add_member".to_owned(), group_id: Some(group_id), name: None, email: Some(email), pubkey: Some(pubkey), member_user_id: None, ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. They are admitted within a minute, once your encryption key is loaded.") } /// Queue a fresh invite code for a group. /// /// The expiry is the one value with a shape to check, and blank is a real /// answer rather than a mistake: the server has a default and saying nothing /// takes it. pub(super) fn create_invite( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let group_id = request .payload .get("group_id") .unwrap_or_default() .trim() .to_owned(); if group_id.is_empty() { return Err(RouteError::conflict("An invite needs a group.")); } let raw = request .payload .get("expires_in_hours") .unwrap_or_default() .trim() .to_owned(); let expires_in_hours = if raw.is_empty() { None } else { Some( raw.parse::() .ok() .filter(|hours| *hours > 0) .ok_or_else(|| { RouteError::conflict("Hours has to be a whole number above zero, or blank.") })?, ) }; crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "create_invite".to_owned(), group_id: Some(group_id), expires_in_hours, ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. The code appears under the group within a minute.") } /// Queue a revoke, from either the group's list or the confirmations section. /// /// One route for both because it is one act: cancelling an invitation is the /// same server call whether nobody has used it yet or somebody has and their /// fingerprint did not match. pub(super) fn revoke_invite( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let (group_id, invitation_id) = addressed(request)?; crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "revoke_invite".to_owned(), group_id: Some(group_id), invitation_id: Some(invitation_id), ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. The invitation is cancelled within a minute.") } /// Queue a confirm: admit the holder of the key on this invitation. /// /// Carries the group and the invitation and nothing else. The fingerprint the /// admin just read is deliberately not sent: the drainer asks the server what /// key it is holding now, so a key swapped between the reading and the drain is /// refused by the same comparison rather than waved through by a copy of it. pub(super) fn confirm_invite( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let (group_id, invitation_id) = addressed(request)?; crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "confirm_invite".to_owned(), group_id: Some(group_id), invitation_id: Some(invitation_id), ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. They are admitted within a minute, once your encryption key is loaded.") } /// The group and invitation a row's act names, both from the path. fn addressed(request: &quasi_router::Request) -> Result<(String, String), RouteError> { let capture = |name: &str| { request .captures .get(name) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| RouteError::not_found("no such invitation")) }; Ok((capture("group_id")?, capture("invitation_id")?)) } /// Queue a read of what a pasted code leads to. /// /// Normalised here rather than in the drainer, so the one accepted spelling of a /// code lives in [`normalize_invite_token`] and a queued row already holds the /// value the server will be asked about. pub(super) fn preview_invite( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let token = normalize_invite_token(request.payload.get("token").unwrap_or_default()); if token.is_empty() { return Err(RouteError::conflict("Paste the code you were given.")); } crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "preview_invite".to_owned(), invite_token: Some(token), ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. What the code leads to appears here within a minute, before anything is sent.") } /// Queue an accept of the code this device is holding an answer about. /// /// Reads the token from the stored preview rather than from the request: the /// Accept control appears only because a preview is on screen, and re-sending /// the code through the form would let the two disagree. pub(super) fn accept_invite(app: &AppState) -> Result<&'static str, RouteError> { let conn = conn(app)?; let held = directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?; drop(conn); let preview = held.ok_or_else(|| { RouteError::not_found("There is no invite code waiting to be accepted here.") })?; // Terminal states are drawn without an Accept control, so reaching this is // a stale screen rather than a misuse. Refused rather than queued: it can // only fail, and failing here says so now instead of in a minute. if !preview.redeemable { return Err(RouteError::conflict( "That code cannot be used any more. Dismiss it and ask for another.", )); } crate::group_queue::enqueue( app, &crate::group_queue::QueuedOp { id: uuid::Uuid::new_v4().to_string(), kind: "accept_invite".to_owned(), invite_token: Some(preview.token), ..Default::default() }, ) .map_err(RouteError::internal)?; Ok("Queued. Your key is sent within a minute; the group appears once its admin confirms it.") } /// Forget the previewed code. /// /// A local delete and not a queued write, because there is nothing to tell a /// server: a preview was a read, and dropping the answer to it is this device's /// business alone. pub(super) fn dismiss_preview(app: &AppState) -> Result<&'static str, RouteError> { let conn = conn(app)?; directory::clear_preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?; Ok("Forgotten.") } /// Take a queued write back out. pub(super) fn cancel( app: &AppState, request: &quasi_router::Request, ) -> Result<&'static str, RouteError> { let id = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no queued action"))?; if crate::group_queue::cancel(app, id).map_err(RouteError::internal)? { Ok("Taken back out of the queue.") } else { // Either it never existed or the drainer got to it first, and the second // is the interesting one: a cancel that raced a success must not report // that it undid anything, because it did not. Err(RouteError::not_found( "That action is not in the queue any more.", )) } }