A library for ATProtocol identities.

feature: introduced crates/atproto-client/src/bin/atproto-client-put-record.rs tool

Changed files
+171
crates
atproto-client
+6
crates/atproto-client/Cargo.toml
··· 35 35 doc = true 36 36 required-features = ["clap"] 37 37 38 + [[bin]] 39 + name = "atproto-client-put-record" 40 + test = false 41 + bench = false 42 + doc = true 43 + 38 44 [dependencies] 39 45 atproto-identity.workspace = true 40 46 atproto-oauth.workspace = true
+165
crates/atproto-client/src/bin/atproto-client-put-record.rs
··· 1 + //! AT Protocol client tool for writing records to a repository. 2 + //! 3 + //! This binary tool creates or updates records in an AT Protocol repository 4 + //! using app password authentication. It resolves the subject to a DID, 5 + //! creates a session, and writes the record using the putRecord XRPC method. 6 + //! 7 + //! # Usage 8 + //! 9 + //! ```text 10 + //! ATPROTO_PASSWORD=<password> atproto-client-put-record <subject> <record_key> <record_json> 11 + //! ``` 12 + //! 13 + //! # Environment Variables 14 + //! 15 + //! - `ATPROTO_PASSWORD` - Required. App password for authentication. 16 + //! - `CERTIFICATE_BUNDLES` - Custom CA certificate bundles. 17 + //! - `USER_AGENT` - Custom user agent string. 18 + //! - `DNS_NAMESERVERS` - Custom DNS nameservers. 19 + //! - `PLC_HOSTNAME` - Override PLC hostname (default: plc.directory). 20 + 21 + use anyhow::Result; 22 + use atproto_client::{ 23 + client::{AppPasswordAuth, Auth}, 24 + com::atproto::{ 25 + repo::{put_record, PutRecordRequest, PutRecordResponse}, 26 + server::create_session, 27 + }, 28 + errors::CliError, 29 + }; 30 + use atproto_identity::{ 31 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 32 + plc, 33 + resolve::{HickoryDnsResolver, resolve_subject}, 34 + web, 35 + }; 36 + use std::env; 37 + 38 + fn print_usage() { 39 + eprintln!("Usage: atproto-client-put-record <subject> <record_key> <record_json>"); 40 + eprintln!(); 41 + eprintln!("Arguments:"); 42 + eprintln!(" <subject> Handle or DID of the repository owner"); 43 + eprintln!(" <record_key> Record key (rkey) for the record"); 44 + eprintln!(" <record_json> JSON record data (must include $type field)"); 45 + eprintln!(); 46 + eprintln!("Environment Variables:"); 47 + eprintln!(" ATPROTO_PASSWORD Required. App password for authentication."); 48 + eprintln!(" CERTIFICATE_BUNDLES Custom CA certificate bundles."); 49 + eprintln!(" USER_AGENT Custom user agent string."); 50 + eprintln!(" DNS_NAMESERVERS Custom DNS nameservers."); 51 + eprintln!(" PLC_HOSTNAME Override PLC hostname (default: plc.directory)."); 52 + } 53 + 54 + #[tokio::main] 55 + async fn main() -> Result<()> { 56 + let args: Vec<String> = env::args().collect(); 57 + 58 + if args.len() != 4 { 59 + print_usage(); 60 + std::process::exit(1); 61 + } 62 + 63 + let subject = &args[1]; 64 + let record_key = &args[2]; 65 + let record_json = &args[3]; 66 + 67 + // Get password from environment variable 68 + let password = env::var("ATPROTO_PASSWORD").map_err(|_| { 69 + anyhow::anyhow!("ATPROTO_PASSWORD environment variable is required") 70 + })?; 71 + 72 + // Set up HTTP client configuration 73 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 74 + let default_user_agent = format!( 75 + "atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)", 76 + version()? 77 + ); 78 + let user_agent = default_env("USER_AGENT", &default_user_agent); 79 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 80 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 81 + 82 + let mut client_builder = reqwest::Client::builder(); 83 + for ca_certificate in certificate_bundles.as_ref() { 84 + let cert = std::fs::read(ca_certificate)?; 85 + let cert = reqwest::Certificate::from_pem(&cert)?; 86 + client_builder = client_builder.add_root_certificate(cert); 87 + } 88 + 89 + client_builder = client_builder.user_agent(user_agent); 90 + let http_client = client_builder.build()?; 91 + 92 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 93 + 94 + // Parse the record JSON 95 + let record: serde_json::Value = serde_json::from_str(record_json).map_err(|err| { 96 + tracing::error!(error = ?err, "Failed to parse record JSON"); 97 + anyhow::anyhow!("Failed to parse record JSON: {}", err) 98 + })?; 99 + 100 + // Extract collection from $type field 101 + let collection = record 102 + .get("$type") 103 + .and_then(|v| v.as_str()) 104 + .ok_or_else(|| anyhow::anyhow!("Record must contain a $type field for the collection"))? 105 + .to_string(); 106 + 107 + // Resolve subject to DID 108 + let did = resolve_subject(&http_client, &dns_resolver, subject).await?; 109 + 110 + // Get DID document to find PDS endpoint 111 + let document = if did.starts_with("did:plc:") { 112 + plc::query(&http_client, &plc_hostname, &did).await? 113 + } else if did.starts_with("did:web:") { 114 + web::query(&http_client, &did).await? 115 + } else { 116 + anyhow::bail!("Unsupported DID method: {}", did); 117 + }; 118 + 119 + // Get PDS endpoint from the DID document 120 + let pds_endpoints = document.pds_endpoints(); 121 + let pds_endpoint = pds_endpoints 122 + .first() 123 + .ok_or_else(|| CliError::NoPdsEndpointFound { did: did.clone() })?; 124 + 125 + // Create session 126 + let session = create_session(&http_client, pds_endpoint, &did, &password, None).await?; 127 + 128 + // Set up app password authentication 129 + let auth = Auth::AppPassword(AppPasswordAuth { 130 + access_token: session.access_jwt.clone(), 131 + }); 132 + 133 + // Create put record request 134 + let put_request = PutRecordRequest { 135 + repo: session.did.clone(), 136 + collection, 137 + record_key: record_key.clone(), 138 + validate: true, 139 + record, 140 + swap_commit: None, 141 + swap_record: None, 142 + }; 143 + 144 + // Execute put record 145 + let response = put_record(&http_client, &auth, pds_endpoint, put_request).await?; 146 + 147 + match response { 148 + PutRecordResponse::StrongRef { uri, cid, .. } => { 149 + println!( 150 + "{}", 151 + serde_json::to_string_pretty(&serde_json::json!({ 152 + "uri": uri, 153 + "cid": cid 154 + }))? 155 + ); 156 + } 157 + PutRecordResponse::Error(err) => { 158 + let error_message = err.error_message(); 159 + tracing::error!(error = %error_message, "putRecord failed"); 160 + anyhow::bail!("putRecord failed: {}", error_message); 161 + } 162 + } 163 + 164 + Ok(()) 165 + }