Files
maarcadetweet/crates/tauri-app/src-tauri/src/pds_client.rs
T
tomdebone b912132a05 tauri-app: 8a review fixes
- fetchBlob cache keyed by (did, cid), not just cid.
  Security: future per-DID access control on getBlob would
  otherwise leak the first responder's bytes to subsequent
  viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
  on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
  get_blob_after_upload_with_different_did ->
  get_blob_returns_404_for_cross_did_cid_lookup. The
  docstring was misleading — the test only verifies the
  (did,cid) PK on the PDS row, not auth. The renamed name
  matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
  (did, cid) signature.
2026-07-06 18:53:09 +02:00

388 lines
12 KiB
Rust

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<String>) -> 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<String>,
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<serde_json::Value>,
}
/// 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<serde_json::Value> {
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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
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<CreateRecordResp> {
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<CreateRecordResp> {
self.create_record_with(repo, collection, record, true, jwt).await
}
pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
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<RepoWriteResp> {
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<DeleteRecordResp> {
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=<did>&cid=<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<Vec<u8>> {
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<u8>,
content_type: &str,
jwt: &str,
) -> Result<UploadBlobResp> {
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::<UploadBlobResp>().await?)
}
}
/// `com.atproto.uploadBlob` response. Spec'd at
/// <https://atproto.com/specs/blob>. 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,
}