use anyhow::Context; use clap::Subcommand; use knot_record::grant::GrantCollection; use knot_types::AccountDid; use knot_xrpc::legacy_admin; use knot_xrpc::RepoMethod; use serde_json::json; #[derive(Subcommand)] pub enum AdminCommand { /// Mint an invite code CreateInviteCode, /// Manage knot members Member { #[command(subcommand)] command: MemberCommand, }, /// Manage anonymous users on the knot User { #[command(subcommand)] command: UserCommand, }, } #[derive(Subcommand)] pub enum MemberCommand { /// Add an account as a member. Add { subject: AccountDid }, /// Remove a member. Remove { subject: AccountDid }, } #[derive(Subcommand)] pub enum UserCommand { /// Ban an account. Ban { subject: AccountDid }, /// Unban an account. Unban { subject: AccountDid }, } pub async fn run(config: &knot_config::Validated, command: AdminCommand) -> anyhow::Result<()> { let response = match command { AdminCommand::CreateInviteCode => { post(config, legacy_admin::CREATE_INVITE_CODE_ROUTE, None).await? } AdminCommand::Member { command } => { let (method, subject) = match command { MemberCommand::Add { subject } => (RepoMethod::Create, subject), MemberCommand::Remove { subject } => (RepoMethod::Delete, subject), }; grant(config, method, GrantCollection::Member, &subject).await? } AdminCommand::User { command } => { let (method, subject) = match command { UserCommand::Ban { subject } => (RepoMethod::Create, subject), UserCommand::Unban { subject } => (RepoMethod::Delete, subject), }; grant(config, method, GrantCollection::Block, &subject).await? } }; println!("{}", serde_json::to_string_pretty(&response)?); Ok(()) } async fn grant( config: &knot_config::Validated, method: RepoMethod, collection: GrantCollection, subject: &AccountDid, ) -> anyhow::Result { let repo = config.knot_hostname().knot_did(); let body = match method { RepoMethod::Create => json!({ "repo": repo.as_str(), "collection": collection.nsid(), "rkey": subject.as_str(), "record": { "$type": collection.nsid() }, }), RepoMethod::Delete => json!({ "repo": repo.as_str(), "collection": collection.nsid(), "rkey": subject.as_str(), }), }; post(config, method.route(), Some(body)).await } async fn post( config: &knot_config::Validated, route: &str, body: Option, ) -> anyhow::Result { let name = config .acl .legacy_admin_secret_env .as_deref() .context("acl.legacy_admin_secret_env isn't configured")?; let secret = zeroize::Zeroizing::new(std::env::var(name).unwrap_or_default()); let secret = secret.trim(); anyhow::ensure!( !secret.is_empty(), "the environment variable {name} is unset or empty" ); let url = format!("https://{}{route}", config.server.hostname); let request = reqwest::Client::new() .post(&url) .basic_auth("admin", Some(secret)); let request = match body { Some(body) => request.json(&body), None => request, }; let response = request .send() .await .with_context(|| format!("request {url}"))?; let status = response.status(); let body = response.text().await.context("read response body")?; anyhow::ensure!(status.is_success(), "{url} responded {status}: {body}"); serde_json::from_str(&body).context("decode response") }