johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! `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<String>,
members: Vec<MemberView>,
}
/// `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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
joined_at: Option<String>,
}
/// `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<Arc<App>>,
axum::Extension(viewer): axum::Extension<crate::viewer::Viewer>,
Query(p): Query<ProfileParams>,
) -> 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<OrgView> {
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<String> {
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<String> = 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<Arc<App>> {
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<String>,
}
#[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<String>,
}
/// 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<Option<String>>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<InviteInput>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<RemoveInput>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<MembershipInput>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<EndInput>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
) -> Result<Response> {
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
}