//! `dev.knotless.getOrg`, and the organizations an account's profile //! names, as the indexes have them ([`crate::orgs`]). use super::*; use crate::orgs::Stood; use crate::viewer::Viewer; use axum::Extension; use knotless::env::Ctx; use knotless::org::{self, Role, Standing, Visibility}; /// `dev.knotless.getOrg`'s answer. #[derive(Serialize)] pub struct OrgOutput { org: ActorView, organization: bool, inside: bool, #[serde(skip_serializing_if = "Option::is_none")] gateway: Option, members: Vec, } /// `dev.knotless.getOrg#memberView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct MemberView { actor: ActorView, role: Role, standing: Standing, invited_at: String, #[serde(skip_serializing_if = "Option::is_none")] expires_at: Option, #[serde(skip_serializing_if = "Option::is_none")] joined_at: Option, } /// `dev.knotless.getProfile#orgView`. #[derive(Serialize)] pub struct OrgView { actor: ActorView, } /// `dev.knotless.getOrg`: an organization's members. To anyone, those /// who are members in public; to the organization itself and its members, /// logged in to the pages or asking with a service token, its invitations /// and hidden members too, from its space, which this server keeps an /// index of as its gateway changes it, or from their first look. pub async fn get_org( State(app): State>, axum::Extension(viewer): axum::Extension, Query(p): Query, ) -> Response { match knotless::identity::resolve(&app.ctx, &p.actor).await { Ok(id) => axum::Json(roster(&app, &id.did, viewer.0.as_deref()).await).into_response(), Err(e) => no_profile(e), } } async fn roster(app: &App, org: &str, viewer: Option<&str>) -> OrgOutput { let found = app.org_standings(org); let organization = found.is_some(); // Someone asking may be one who lets the server read its space first. let found = match viewer.filter(|_| organization) { Some(viewer) => { app.keep_org(org, viewer).await; app.org_standings(org) } None => found, }; let all = found.unwrap_or_default(); // Without a space kept, its members see no more than anyone: its // invitations are then in its public records, which only the // organization sees. let kept = app.org_kept(org); let member = |v: &str| { all.iter() .any(|s| s.invitee.did == v && kept && s.standing.joined()) }; let inside = viewer.is_some_and(|v| v == org || member(v)); // Anyone asking sees where they stand themselves, as an invitee does. let theirs = |s: &Stood| viewer == Some(s.invitee.did.as_str()); let shown = all.into_iter(); let shown = shown.filter(|s| inside || theirs(s) || s.standing == Standing::Public); let views = shown.map(|s| async move { MemberView { actor: actor(app, &s.invitee.did).await, role: Role::of(s.invitee.role.as_deref()), standing: s.standing, invited_at: s.invitee.invited_at, expires_at: s.invitee.expires_at, joined_at: s.invitee.joined_at, } }); let views: Vec<_> = views.collect(); let gateway = app .index .as_ref() .and_then(|i| i.org_gateway(org).ok().flatten()); OrgOutput { org: actor(app, org).await, organization, inside, gateway, members: futures::future::join_all(views).await, } } /// Whether `did` is an organization, as the appview's index has its /// public record. pub fn is_org(app: &App, did: &str) -> bool { let found = app.index.as_ref().map(|i| i.is_org(did)); matches!(found, Some(Ok(true))) } /// The organizations `did` is a member of: in public, from the appview's /// index; and to `did` itself, asking, its hidden memberships too, from /// this server's index of the organizations' spaces. pub async fn orgs_of(app: &App, did: &str, viewer: Option<&str>) -> Vec { let all = if viewer == Some(did) { joined(app, did) } else { let open = app.index.as_ref().and_then(|i| i.orgs_of(did, false).ok()); open.unwrap_or_default() }; let views = all.iter().map(|org| async move { OrgView { actor: actor(app, org).await, } }); futures::future::join_all(views).await } /// The organizations `did` has joined, by DID, in public or hidden: the /// owners whose repositories it looks after beside its own. pub fn joined(app: &App, did: &str) -> Vec { let open = app.index.as_ref().and_then(|i| i.orgs_of(did, false).ok()); let hidden = app.spaces.as_ref().and_then(|i| i.orgs_of(did, true).ok()); let mut all: Vec = open.into_iter().chain(hidden).flatten().collect(); all.sort(); all.dedup(); all } /// The changes the pages make to organizations, as the account logged in /// to them, through the same code as `knotless org`. pub fn routes() -> Router> { Router::new() .route("/xrpc/dev.knotless.inviteToOrg", post(invite_to_org)) .route("/xrpc/dev.knotless.removeFromOrg", post(remove_from_org)) .route( "/xrpc/dev.knotless.setOrgMembership", post(set_org_membership), ) .route( "/xrpc/dev.knotless.endOrgMembership", post(end_org_membership), ) .route("/xrpc/dev.knotless.deleteOrg", post(delete_org)) } #[derive(Deserialize)] pub struct InviteInput { org: String, member: String, role: Option, } #[derive(Deserialize)] pub struct RemoveInput { org: String, member: String, } #[derive(Deserialize)] pub struct MembershipInput { org: String, visibility: Visibility, } #[derive(Deserialize)] pub struct EndInput { org: String, } /// What a change to an organization from the pages answers: a warning /// when it was done only in part. #[derive(Serialize)] pub struct Changed { #[serde(skip_serializing_if = "Option::is_none")] warning: Option, } /// The account logged in to the pages, to `act`, as a context acting as /// it, and its DID. fn acting(app: &App, viewer: Viewer, headers: &HeaderMap, act: &str) -> Result<(Ctx, String)> { let did = super::stars::logged_in(app, viewer, headers, act)?; Ok((crate::login::ctx_for(app, &did), did)) } /// Indexes what `change` did to the organization `org` and `member`'s /// place in it, at once, so the page after has it. Its gateway does so /// too, last of all, for a change it made `alone`, but that may be another /// server's: this one's index has it already. A warning says the gateway /// did not get to it. async fn changed( app: &App, org: &str, member: &str, alone: bool, change: anyhow::Result>, ) -> Result { let warning = change.map_err(super::repos::refused)?; let org = knotless::identity::resolve(&app.ctx, org).await.ok(); let indexed = alone && warning.is_none() && org.as_ref().is_some_and(|o| here(app, &o.did)); if indexed { return Ok(answer(Changed { warning })); } let member = knotless::identity::resolve(&app.ctx, member).await.ok(); let both = org.zip(member); let reindexed = both.map(|(org, member)| async move { app.reindex_org(&org.did, &member.did).await }); futures::future::OptionFuture::from(reindexed).await; Ok(answer(Changed { warning })) } /// Whether this server is the gateway the organization `org` acts /// through, as its index has its public record. fn here(app: &App, org: &str) -> bool { let ours = app.gateway.as_ref().map(|g| g.did.as_str()); let named = app .index .as_ref() .and_then(|i| i.org_gateway(org).ok().flatten()); ours.is_some() && named.as_deref() == ours } /// `dev.knotless.inviteToOrg`: as `knotless org add`. pub async fn invite_to_org( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, did) = acting(&app, viewer, &headers, "invite to an organization")?; // Delegated to this server from the command line, its first // invitation goes through it. let ours = app.gateway.as_ref(); let ours = ours.filter(|_| crate::gateway::writes_for(&app, &did)); let ours = ours.filter(|_| knotless::gateway::delegated(&ctx, &did).is_none()); if let Some(gateway) = ours { let _ = knotless::gateway::remember(&ctx, &did, &gateway.did); } let role = Role::of(input.role.as_deref()); let done = org::invite(&ctx, Some(&input.org), &input.member, role).await; changed(&app, &input.org, &input.member, true, done.map(|_| None)).await } /// `dev.knotless.removeFromOrg`: as `knotless org remove`. pub async fn remove_from_org( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, _) = acting(&app, viewer, &headers, "remove from an organization")?; let done = org::remove(&ctx, Some(&input.org), &input.member).await; changed(&app, &input.org, &input.member, true, done.map(|_| None)).await } /// `dev.knotless.setOrgMembership`: as `knotless org join`, which also /// shows or hides a membership. pub async fn set_org_membership( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, did) = acting(&app, viewer, &headers, "join an organization")?; let done = org::join(&ctx, &input.org, input.visibility).await; // Taken in, its old record goes after the gateway indexed it: this // server takes it out of its index itself when it is that gateway. let found = knotless::identity::resolve(&app.ctx, &input.org).await; let ours = found.ok().filter(|o| done.is_ok() && here(&app, &o.did)); let unjoined = ours.map(|o| app.unjoined(&o.did, &did, input.visibility)); changed( &app, &input.org, &did, unjoined.is_some(), done.map(|_| None), ) .await } /// `dev.knotless.endOrgMembership`: as `knotless org leave`, which also /// declines an invitation. pub async fn end_org_membership( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, did) = acting(&app, viewer, &headers, "leave an organization")?; let done = org::leave(&ctx, &input.org).await; changed( &app, &input.org, &did, true, done.map(|(_, warning)| warning.as_ref().map(crate::shown)), ) .await } /// `dev.knotless.deleteOrg`: as `knotless org delete --yes`, for the /// organization logged in. pub async fn delete_org( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, ) -> Result { let (ctx, did) = acting(&app, viewer, &headers, "delete an organization")?; let done = org::delete(&ctx).await; changed(&app, &did, &did, false, done.map(|_| None)).await }