~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
324 lines · 11.1 KB Raw History
//! `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
}