use anyhow::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; #[derive(Clone)] pub struct PdsHttpClient { pub base_url: String, pub client: Client, } impl PdsHttpClient { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), client: Client::builder() .timeout(Duration::from_secs(10)) .build() .unwrap(), } } } #[derive(Debug, Serialize, Deserialize)] pub struct CreateAccountReq { pub handle: String, pub email: Option, pub password: String, } #[derive(Debug, Serialize, Deserialize)] pub struct AccountSession { pub did: String, pub handle: String, pub access_jwt: String, pub refresh_jwt: String, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateRecordReq { pub repo: String, pub collection: String, pub record: serde_json::Value, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateRecordResp { pub uri: String, pub cid: String, } /// `app.bsky.feed.like.create` request body — the flat BSky shape. /// The Tauri command always uses this shape (rather than the /// generic `createRecord` body) so the PDS can hardcode the /// collection. #[derive(Debug, Serialize, Deserialize)] pub struct CreateLikeBody { pub repo: String, pub subject: SubjectRef, #[serde(rename = "createdAt")] pub created_at: String, } #[derive(Debug, Serialize, Deserialize)] pub struct SubjectRef { pub uri: String, pub cid: String, } /// `com.atproto.repo.deleteRecord` body. Reused for unlike and /// unrepost — the caller just sets `collection` to /// `app.bsky.feed.like` or `app.bsky.feed.repost`. #[derive(Debug, Serialize, Deserialize)] pub struct DeleteRecordBody { pub repo: String, pub collection: String, pub rkey: String, } /// Response from `feed.like.create` and (structurally) any /// `createRecord` variant. The PDS returns `{uri, cid, commit}`. #[derive(Debug, Serialize, Deserialize)] pub struct RepoWriteResp { pub uri: String, pub cid: String, #[serde(default)] pub commit: Option, } /// Response from `com.atproto.repo.deleteRecord`. Spec returns /// `{ commit: { cid, rev } }`. #[derive(Debug, Serialize, Deserialize)] pub struct DeleteRecordResp { pub commit: serde_json::Value, } impl PdsHttpClient { pub async fn describe_server(&self) -> Result { let r = self .client .get(format!("{}/xrpc/com.atproto.server.describeServer", self.base_url)) .send() .await? .json() .await?; Ok(r) } pub async fn create_account( &self, handle: &str, password: &str, ) -> Result { let body = CreateAccountReq { handle: handle.to_string(), email: None, password: password.to_string(), }; let r = self .client .post(format!("{}/xrpc/com.atproto.server.createAccount", self.base_url)) .json(&body) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("createAccount failed: {} {}", status, text); } Ok(r.json().await?) } pub async fn create_session( &self, identifier: &str, password: &str, ) -> Result { let r = self .client .post(format!("{}/xrpc/com.atproto.server.createSession", self.base_url)) .json(&serde_json::json!({"identifier": identifier, "password": password})) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("createSession failed: {} {}", status, text); } Ok(r.json().await?) } pub async fn refresh_session(&self, refresh_jwt: &str) -> Result { let r = self .client .post(format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url)) .json(&serde_json::json!({"refresh_jwt": refresh_jwt})) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("refreshSession failed: {} {}", status, text); } Ok(r.json().await?) } pub async fn create_record_with( &self, repo: &str, collection: &str, record: serde_json::Value, _validate: bool, jwt: &str, ) -> Result { let r = self .client .post(format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url)) .bearer_auth(jwt) .json(&CreateRecordReq { repo: repo.to_string(), collection: collection.to_string(), record, }) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("createRecord failed: {} {}", status, text); } Ok(r.json().await?) } pub async fn create_record( &self, repo: &str, collection: &str, record: serde_json::Value, jwt: &str, ) -> Result { self.create_record_with(repo, collection, record, true, jwt).await } pub async fn resolve_handle(&self, handle: &str) -> Result> { let r = self .client .post(format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url)) .json(&serde_json::json!({"handle": handle})) .send() .await?; if r.status().as_u16() == 404 { return Ok(None); } if !r.status().is_success() { anyhow::bail!("resolveHandle failed: {}", r.status()); } let v: serde_json::Value = r.json().await?; Ok(v.get("did").and_then(|x| x.as_str()).map(String::from)) } /// `POST /xrpc/com.atproto.feed.like.create` /// /// `repo` is the caller's DID; the JWT authenticates the call /// and must match. `subject` is the post being liked /// (`strongRef` = `{uri, cid}`). pub async fn create_like( &self, repo: &str, subject_uri: &str, subject_cid: &str, jwt: &str, ) -> Result { let r = self .client .post(format!( "{}/xrpc/com.atproto.feed.like.create", self.base_url )) .bearer_auth(jwt) .json(&CreateLikeBody { repo: repo.to_string(), subject: SubjectRef { uri: subject_uri.to_string(), cid: subject_cid.to_string(), }, created_at: chrono::Utc::now().to_rfc3339(), }) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("feed.like.create failed: {} {}", status, text); } Ok(r.json().await?) } /// `POST /xrpc/com.atproto.repo.deleteRecord` /// /// Generic record deletion — `collection` is the NSID /// (`app.bsky.feed.like`, `app.bsky.feed.repost`, etc.) and /// `rkey` is the trailing component of the record's URI. pub async fn delete_record( &self, repo: &str, collection: &str, rkey: &str, jwt: &str, ) -> Result { let r = self .client .post(format!( "{}/xrpc/com.atproto.repo.deleteRecord", self.base_url )) .bearer_auth(jwt) .json(&DeleteRecordBody { repo: repo.to_string(), collection: collection.to_string(), rkey: rkey.to_string(), }) .send() .await?; if !r.status().is_success() { let status = r.status(); let text = r.text().await.unwrap_or_default(); anyhow::bail!("repo.deleteRecord failed: {} {}", status, text); } Ok(r.json().await?) } /// `GET /xrpc/com.atproto.sync.getBlob?did=&cid=` /// /// Streams raw blob bytes from the user's PDS. `jwt` is currently /// unused — `com.atproto.sync.getBlob` is unauthenticated in this /// implementation, matching the other sync reads — but we keep /// the parameter in the signature so future auth-gated calls don't /// force a wire-format change. pub async fn get_blob( &self, did: &str, cid: &str, jwt: Option<&str>, ) -> Result> { let mut req = self.client.get(format!( "{}/xrpc/com.atproto.sync.getBlob", self.base_url )); if let Some(t) = jwt { req = req.bearer_auth(t); } let resp = req .query(&[("did", did), ("cid", cid)]) .send() .await?; if !resp.status().is_success() { let status = resp.status(); // Try to capture the XRPC error body for easier debugging. let body = resp.text().await.unwrap_or_default(); anyhow::bail!("sync.getBlob failed: {} {}", status, body); } let bytes = resp.bytes().await?; Ok(bytes.to_vec()) } /// `POST /xrpc/com.atproto.uploadBlob` /// /// Authenticated; the server derives the DID from the JWT `sub` /// claim and writes the block to `(did, sha256(cid))` in /// `repo_blocks`. The body is the raw blob bytes and the /// `Content-Type` header is mandatory — the PDS uses it as the /// authoritative MIME type for the row. /// /// Returns the parsed `com.atproto.uploadBlob` response verbatim: /// `{ blob: { $type, ref: { $link }, mimeType, size } }`. The /// caller is expected to forward this to the Svelte UI so it can /// drop the blob ref straight into a record's `embed.images[]`. pub async fn upload_blob( &self, bytes: Vec, content_type: &str, jwt: &str, ) -> Result { let resp = self .client .post(format!("{}/xrpc/com.atproto.uploadBlob", self.base_url)) .bearer_auth(jwt) .header(reqwest::header::CONTENT_TYPE, content_type) .body(bytes) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("uploadBlob failed: {} {}", status, body); } Ok(resp.json::().await?) } } /// `com.atproto.uploadBlob` response. Spec'd at /// . The server returns a `blob` /// object that mirrors what an `app.bsky.embed.images#image` entry /// expects on the wire — the Tauri command forwards this verbatim so /// the UI can drop it into the post record with no further /// transformation. #[derive(Debug, Serialize, Deserialize)] pub struct UploadBlobResp { pub blob: UploadedBlob, } #[derive(Debug, Serialize, Deserialize)] pub struct UploadedBlob { #[serde(rename = "$type")] pub ty: String, #[serde(rename = "ref")] pub blob_ref: UploadedBlobRef, #[serde(rename = "mimeType")] pub mime_type: String, pub size: u64, } #[derive(Debug, Serialize, Deserialize)] pub struct UploadedBlobRef { #[serde(rename = "$link")] pub link: String, } impl PdsHttpClient { /// `POST /xrpc/com.atproto.repo.getRecord?repo=&collection=app.bsky.actor.profile&rkey=self` /// Returns the record's CBOR-decoded value as JSON, or `None` if no /// record exists for that path. The server replies with a /// `{ "value": {...} | null }` envelope; we unwrap and return the /// inner value (which is the `app.bsky.actor.profile` JSON object /// keyed by the deserialized CBOR field names: `displayName`, /// `description`, `avatar`/{ ref, mimeType, size }, `banner`/...). pub async fn get_profile_record( &self, repo: &str, jwt: &str, ) -> Result> { let url = format!( "{}/xrpc/com.atproto.repo.getRecord", self.base_url ); let r = self .client .get(&url) .query(&[("repo", repo), ("collection", "app.bsky.actor.profile"), ("rkey", "self")]) .bearer_auth(jwt) .send() .await?; if r.status().as_u16() == 404 { return Ok(None); } if !r.status().is_success() { let s = r.status(); let body = r.text().await.unwrap_or_default(); anyhow::bail!("getRecord returned {s}: {body}"); } let v: serde_json::Value = r.json().await?; Ok(v.get("value").cloned().and_then(|x| if x.is_null() { None } else { Some(x) })) } /// `POST /xrpc/app.bsky.actor.profile.set` — PDS-only convenience /// endpoint that does a read-modify-write of the profile record. The /// request body has the same shape as `app.bsky.actor.profile` minus /// the `$type` (added server-side). pub async fn set_profile( &self, repo: &str, profile: &serde_json::Value, jwt: &str, ) -> Result { let url = format!("{}/xrpc/app.bsky.actor.profile.set", self.base_url); let r = self .client .post(&url) .bearer_auth(jwt) .json(profile) .send() .await?; if !r.status().is_success() { let s = r.status(); let body = r.text().await.unwrap_or_default(); anyhow::bail!("setProfile returned {s}: {body}"); } let v: serde_json::Value = r.json().await?; Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null)) } }