1use 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#[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
49pub struct BlossomClient {
53 http: HttpClient,
54 server: String,
55 keys: Keys,
56 auth_ttl_secs: u64,
57}
58
59#[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 pub fn with_auth_ttl(mut self, secs: u64) -> Self {
83 self.auth_ttl_secs = secs;
84 self
85 }
86
87 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 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 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 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}