use anyhow::{Context, bail}; use serde::Deserialize; #[derive(Clone)] pub struct Kubo { client: reqwest::Client, api_url: String, } #[derive(Deserialize)] struct AddResponse { #[serde(rename = "Hash")] hash: String, } impl Kubo { pub fn new(api_url: String) -> Self { Self { client: reqwest::Client::new(), api_url, } } /// Adds bytes to IPFS using Kubo's normal chunked UnixFS add (default /// params — no raw-leaves / single-block tricks) and returns the CID. pub async fn add(&self, bytes: Vec) -> anyhow::Result { let part = reqwest::multipart::Part::bytes(bytes).file_name("blob"); let form = reqwest::multipart::Form::new().part("file", part); let resp = self .client .post(format!("{}/api/v0/add", self.api_url)) .multipart(form) .send() .await .context("failed to reach Kubo daemon for /api/v0/add")?; if !resp.status().is_success() { bail!("kubo add failed: HTTP {}", resp.status()); } let body = resp.text().await?; let line = body .lines() .find(|l| !l.trim().is_empty()) .context("empty response from kubo add")?; let parsed: AddResponse = serde_json::from_str(line) .with_context(|| format!("failed to parse kubo add response: {line}"))?; Ok(parsed.hash) } /// Explicitly (recursively) pins a CID, decoupled from add-time pinning. pub async fn pin_add(&self, cid: &str) -> anyhow::Result<()> { let resp = self .client .post(format!("{}/api/v0/pin/add", self.api_url)) .query(&[("arg", cid), ("recursive", "true")]) .send() .await .context("failed to reach Kubo daemon for /api/v0/pin/add")?; if !resp.status().is_success() { bail!("kubo pin/add failed for {cid}: HTTP {}", resp.status()); } Ok(()) } /// Streams the full reassembled file content for a CID via /api/v0/cat. pub async fn cat(&self, cid: &str) -> anyhow::Result { let resp = self .client .post(format!("{}/api/v0/cat", self.api_url)) .query(&[("arg", cid)]) .send() .await .context("failed to reach Kubo daemon for /api/v0/cat")?; if !resp.status().is_success() { bail!("kubo cat failed for {cid}: HTTP {}", resp.status()); } Ok(resp) } /// Verifies the local Kubo daemon is actually reachable, for a clear /// upfront error instead of a confusing failure mid-transfer. pub async fn healthcheck(&self) -> anyhow::Result<()> { let resp = self .client .post(format!("{}/api/v0/id", self.api_url)) .send() .await .with_context(|| { format!( "no IPFS (Kubo) daemon reachable at {} — start one with `ipfs daemon`", self.api_url ) })?; if !resp.status().is_success() { bail!("kubo daemon at {} returned HTTP {}", self.api_url, resp.status()); } Ok(()) } }