Skip to main content

paygress/
blossom.rs

1// Blossom client (Unit 6 of the 12-month plan,
2// docs/plans/2026-04-26-001-feat-paygress-12mo-vision-plan.md).
3//
4// In-tree implementation of the BUD-01 / BUD-02 / BUD-04 subset
5// Paygress needs for warm-standby checkpoint storage:
6//   - PUT /upload  (BUD-02): upload a blob; server returns
7//     `{ url, sha256, size, type, uploaded }`.
8//   - GET /<sha256> (BUD-01): fetch by hash.
9//   - DELETE /<sha256> (BUD-04): remove a blob (auth required).
10//
11// Auth: NIP-98-style Nostr event of kind 24242, base64-encoded
12// JSON in `Authorization: Nostr <b64>`. Tags:
13//   - ["t", "upload"|"delete"|"get"] — operation.
14//   - ["x", "<sha256>"] — content hash (post-encryption).
15//   - ["expiration", "<unix_ts>"] — short-lived (60s default).
16//
17// Encryption is client-side and orthogonal: callers encrypt before
18// `put` and decrypt after `get`, using `crate::blossom_crypto`.
19// The Blossom server only ever sees ciphertext.
20
21use anyhow::{Context, Result};
22use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
23use nostr_sdk::{EventBuilder, Keys, Kind, Tag, Timestamp};
24use reqwest::Client as HttpClient;
25use serde::Deserialize;
26
27const AUTH_KIND: u16 = 24242;
28const DEFAULT_AUTH_TTL_SECS: u64 = 60;
29
30/// Operation tagged in the auth event's `t` tag. Mirrors the
31/// Blossom spec wording.
32#[derive(Debug, Clone, Copy)]
33pub enum BlossomOp {
34    Upload,
35    Get,
36    Delete,
37}
38
39impl BlossomOp {
40    fn tag_value(self) -> &'static str {
41        match self {
42            BlossomOp::Upload => "upload",
43            BlossomOp::Get => "get",
44            BlossomOp::Delete => "delete",
45        }
46    }
47}
48
49/// Minimal client. Holds a long-lived `reqwest::Client`, the target
50/// Blossom server URL, and the Nostr `Keys` used to sign auth
51/// events. One client per (server, identity) pair.
52pub struct BlossomClient {
53    http: HttpClient,
54    server: String,
55    keys: Keys,
56    auth_ttl_secs: u64,
57}
58
59/// Response shape from `PUT /upload` (BUD-02 §3).
60#[derive(Debug, Clone, Deserialize)]
61pub struct UploadResponse {
62    pub url: String,
63    pub sha256: String,
64    pub size: u64,
65    #[serde(rename = "type", default)]
66    pub mime_type: Option<String>,
67    #[serde(default)]
68    pub uploaded: u64,
69}
70
71impl BlossomClient {
72    pub fn new(server: impl Into<String>, keys: Keys) -> Self {
73        Self {
74            http: HttpClient::new(),
75            server: server.into().trim_end_matches('/').to_string(),
76            keys,
77            auth_ttl_secs: DEFAULT_AUTH_TTL_SECS,
78        }
79    }
80
81    /// Override the auth-event TTL. Most callers don't need this.
82    pub fn with_auth_ttl(mut self, secs: u64) -> Self {
83        self.auth_ttl_secs = secs;
84        self
85    }
86
87    /// Upload `bytes` (already-encrypted ciphertext). Returns the
88    /// server's response; the `sha256` field is what callers should
89    /// persist as the checkpoint's content address.
90    pub async fn put(&self, bytes: Vec<u8>) -> Result<UploadResponse> {
91        let hash = crate::blossom_crypto::sha256_hex(&bytes);
92        let auth = self.build_auth_header(BlossomOp::Upload, &hash).await?;
93
94        let url = format!("{}/upload", self.server);
95        let resp = self
96            .http
97            .put(&url)
98            .header("Authorization", auth)
99            .body(bytes)
100            .send()
101            .await
102            .with_context(|| format!("PUT {}", url))?;
103
104        if !resp.status().is_success() {
105            anyhow::bail!(
106                "Blossom upload returned {}: {}",
107                resp.status(),
108                resp.text().await.unwrap_or_default()
109            );
110        }
111
112        let parsed: UploadResponse = resp.json().await.context("parse Blossom upload response")?;
113        Ok(parsed)
114    }
115
116    /// Fetch by hash. Returns the wire-format bytes (still
117    /// encrypted — caller decrypts via `blossom_crypto`).
118    pub async fn get(&self, sha256: &str) -> Result<Vec<u8>> {
119        let url = format!("{}/{}", self.server, sha256);
120        let resp = self
121            .http
122            .get(&url)
123            .send()
124            .await
125            .with_context(|| format!("GET {}", url))?;
126        if !resp.status().is_success() {
127            anyhow::bail!("Blossom fetch returned {}", resp.status());
128        }
129        Ok(resp.bytes().await?.to_vec())
130    }
131
132    /// Delete by hash. Auth-required.
133    pub async fn delete(&self, sha256: &str) -> Result<()> {
134        let auth = self.build_auth_header(BlossomOp::Delete, sha256).await?;
135        let url = format!("{}/{}", self.server, sha256);
136        let resp = self
137            .http
138            .delete(&url)
139            .header("Authorization", auth)
140            .send()
141            .await
142            .with_context(|| format!("DELETE {}", url))?;
143        if !resp.status().is_success() {
144            anyhow::bail!("Blossom delete returned {}", resp.status());
145        }
146        Ok(())
147    }
148
149    /// Build the `Authorization: Nostr <base64>` header value. Pure
150    /// (no I/O), so unit-testable.
151    pub async fn build_auth_header(&self, op: BlossomOp, x_hash: &str) -> Result<String> {
152        let now = std::time::SystemTime::now()
153            .duration_since(std::time::UNIX_EPOCH)?
154            .as_secs();
155        let expiration = now + self.auth_ttl_secs;
156
157        let exp_str = expiration.to_string();
158        let tags = vec![
159            Tag::parse(["t", op.tag_value()])?,
160            Tag::parse(["x", x_hash])?,
161            Tag::parse(["expiration", exp_str.as_str()])?,
162        ];
163
164        let event = EventBuilder::new(Kind::Custom(AUTH_KIND), "")
165            .tags(tags)
166            .custom_created_at(Timestamp::from(now))
167            .sign_with_keys(&self.keys)?;
168
169        let json = serde_json::to_string(&event)?;
170        Ok(format!("Nostr {}", BASE64.encode(json.as_bytes())))
171    }
172}