swarm-scout 0.6.1

scout — a light, no-node client for reading content from Swarm: point at a gateway (or any Bee) and cat / get / bytes by reference.
Documentation
//! scout — a light Swarm client.
//!
//! [`LiteClient`] is a thin façade over [`bee`]. Reads (`/bzz`, `/bytes`)
//! go to a gateway or any Bee endpoint and need no node, stake or stamp —
//! the "light node" idea. Writes (uploads) need a Bee node holding a
//! usable postage batch; configure one with [`LiteClient::with_write`] and
//! they go there while reads still use the read endpoint.

use bee::api::{CollectionUploadOptions, DownloadOptions, FileUploadOptions, UploadOptions};
use bee::swarm::{BatchId, PrivateKey, PublicKey, Reference, Topic};
use bee::Client;

/// A light Swarm client: a read endpoint, plus an optional stamped node
/// for uploads.
pub struct LiteClient {
    client: Client,
    write: Option<Writer>,
}

struct Writer {
    client: Client,
    batch: BatchId,
}

/// Everything a `share` produces — the refs to download it and to manage
/// access.
pub struct ShareInfo {
    pub file_ref: String,
    pub history: String,
    pub grantee_ref: String,
    pub grantee_history: String,
    pub publisher: String,
}

impl LiteClient {
    /// Build a read client pointed at `endpoint` (a public gateway or any
    /// Bee HTTP base URL).
    pub fn read(endpoint: &str) -> anyhow::Result<Self> {
        Ok(Self {
            client: Client::new(endpoint)?,
            write: None,
        })
    }

    /// Attach a write endpoint: a Bee node holding a usable postage batch
    /// (`stamp`, hex). Uploads go here; reads keep using the read endpoint.
    pub fn with_write(mut self, node_endpoint: &str, stamp: &str) -> anyhow::Result<Self> {
        let batch: BatchId = stamp.parse()?;
        self.write = Some(Writer {
            client: Client::new(node_endpoint)?,
            batch,
        });
        Ok(self)
    }

    // ---------- read (no node required) ----------

    /// Download manifest-aware content at `reference` (`GET /bzz/{ref}`).
    pub async fn cat(&self, reference: &str) -> anyhow::Result<Vec<u8>> {
        let r: Reference = reference.parse()?;
        let (bytes, _headers) = self.client.file().download_file(&r, None).await?;
        Ok(bytes.to_vec())
    }

    /// Download one path inside a collection (`GET /bzz/{ref}/{path}`).
    pub async fn cat_path(&self, reference: &str, path: &str) -> anyhow::Result<Vec<u8>> {
        let r: Reference = reference.parse()?;
        let (bytes, _headers) = self.client.file().download_file_path(&r, path, None).await?;
        Ok(bytes.to_vec())
    }

    /// Download raw bytes (`GET /bytes/{ref}`).
    pub async fn bytes(&self, reference: &str) -> anyhow::Result<Vec<u8>> {
        let r: Reference = reference.parse()?;
        let bytes = self.client.file().download_data(&r, None).await?;
        Ok(bytes.to_vec())
    }

    // ---------- write (needs a stamped node) ----------

    fn writer(&self) -> anyhow::Result<&Writer> {
        self.write.as_ref().ok_or_else(|| {
            anyhow::anyhow!("uploads need a write endpoint — set --node and --stamp (or $BEE_NODE/$BEE_STAMP)")
        })
    }

    /// Upload a file (manifest-wrapped, `POST /bzz`). Returns the bzz
    /// reference as hex.
    pub async fn up_file(&self, name: &str, content_type: &str, data: Vec<u8>) -> anyhow::Result<String> {
        let w = self.writer()?;
        let res = w
            .client
            .file()
            .upload_file(&w.batch, data, name, content_type, None)
            .await?;
        Ok(res.reference.to_hex())
    }

    /// Upload raw bytes (`POST /bytes`). Returns the bytes reference as hex.
    pub async fn up_bytes(&self, data: Vec<u8>) -> anyhow::Result<String> {
        let w = self.writer()?;
        let res = w.client.file().upload_data(&w.batch, data, None).await?;
        Ok(res.reference.to_hex())
    }

    /// Upload a whole directory as a browsable Swarm collection (a
    /// mantaray manifest). `index_document` (e.g. `index.html`) is served
    /// as the default document at `bzz/<ref>/`. Returns the manifest
    /// reference. Needs a write endpoint (`--stamp`).
    pub async fn up_dir(&self, folder: &str, index_document: Option<&str>) -> anyhow::Result<String> {
        let w = self.writer()?;
        let opts = CollectionUploadOptions {
            index_document: index_document.map(|s| s.to_string()),
            ..Default::default()
        };
        let res = w.client.file().upload_collection(&w.batch, folder, Some(&opts)).await?;
        Ok(res.reference.to_hex())
    }

    // ---------- feeds (mutable pointers) ----------

    /// Point a feed (`key` owner + `topic`) at `reference` and ensure a
    /// feed *manifest* exists. Returns the **manifest reference** — a
    /// stable handle that always resolves to the feed's latest content.
    /// Read it later with [`LiteClient::cat`]; re-publishing updates what
    /// that same handle serves. Needs a write endpoint (`--stamp`).
    pub async fn publish(&self, key_hex: &str, topic: &str, reference: &str) -> anyhow::Result<String> {
        let w = self.writer()?;
        let key = PrivateKey::from_hex(key_hex)?;
        let t = Topic::from_string(topic);
        let r: Reference = reference.parse()?;
        w.client
            .file()
            .update_feed_with_reference(&w.batch, &key, &t, &r, None)
            .await?;
        let owner = key.public_key()?.address();
        let manifest = w.client.file().create_feed_manifest(&w.batch, &owner, &t).await?;
        Ok(manifest.to_hex())
    }

    // ---------- sharing (Access Control Trie) ----------

    /// Upload `data` under an ACT and grant the given compressed-pubkey
    /// `grantees`. Returns the refs to download it (file_ref + history +
    /// publisher) and to manage access (grantee_ref). Needs `--stamp`.
    pub async fn share(
        &self,
        name: &str,
        content_type: &str,
        data: Vec<u8>,
        grantees: &[String],
    ) -> anyhow::Result<ShareInfo> {
        let w = self.writer()?;
        let opts = FileUploadOptions {
            base: UploadOptions {
                act: Some(true),
                ..Default::default()
            },
            content_type: Some(content_type.to_string()),
            ..Default::default()
        };
        let up = w
            .client
            .file()
            .upload_file(&w.batch, data, name, content_type, Some(&opts))
            .await?;
        let history = up
            .history_address
            .ok_or_else(|| anyhow::anyhow!("upload returned no ACT history address"))?;
        let created = w.client.api().create_grantees(&w.batch, grantees).await?;
        let publisher = w.client.debug().addresses().await?.public_key;
        Ok(ShareInfo {
            file_ref: up.reference.to_hex(),
            history: history.to_hex(),
            grantee_ref: created.reference,
            grantee_history: created.history_reference,
            publisher,
        })
    }

    /// Revoke `remove` grantees from a grantee list anchored at the
    /// upload's `history`. Returns the new (grantee_ref, grantee_history).
    /// Needs `--stamp`.
    pub async fn revoke(
        &self,
        grantee_ref: &str,
        history: &str,
        remove: &[String],
    ) -> anyhow::Result<(String, String)> {
        let w = self.writer()?;
        let gr = Reference::from_hex(grantee_ref)?;
        let h = Reference::from_hex(history)?;
        let patched = w.client.api().patch_grantees(&w.batch, &gr, &h, &[], remove).await?;
        Ok((patched.reference, patched.history_reference))
    }

    /// List the grantees of a grantee-list reference. Read-only.
    pub async fn grantees(&self, grantee_ref: &str) -> anyhow::Result<Vec<String>> {
        let gr = Reference::from_hex(grantee_ref)?;
        Ok(self.client.api().get_grantees(&gr).await?)
    }

    /// Download ACT-protected content as `publisher` (their compressed
    /// pubkey) using the upload's `history`. The Bee node decrypts — it
    /// must be the publisher or an authorised grantee. Read-only.
    pub async fn fetch_act(&self, file_ref: &str, publisher: &str, history: &str) -> anyhow::Result<Vec<u8>> {
        let r = Reference::from_hex(file_ref)?;
        let pk = PublicKey::from_hex(publisher)?;
        let h = Reference::from_hex(history)?;
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        let opts = DownloadOptions {
            act_publisher: Some(pk),
            act_history_address: Some(h),
            act_timestamp: Some(now),
            ..Default::default()
        };
        let (body, _headers) = self.client.file().download_file(&r, Some(&opts)).await?;
        Ok(body.to_vec())
    }
}

/// Generate a fresh identity. Returns `(private_key_hex, owner_address_hex,
/// compressed_pubkey_hex)` — the owner address is the feed identity, the
/// compressed pubkey is what ACT `share --to` expects.
pub fn generate_key() -> anyhow::Result<(String, String, String)> {
    let mut b = [0u8; 32];
    loop {
        getrandom::getrandom(&mut b).map_err(|e| anyhow::anyhow!("rng: {e}"))?;
        if let Ok(key) = PrivateKey::new(&b) {
            let pubk = key.public_key()?;
            return Ok((key.to_hex(), pubk.address().to_hex(), pubk.compressed_hex()?));
        }
    }
}