Skip to content

Commit e926ffd

Browse files
fix(sendadmindmattach): Blossom upload auth and response handling
- Use BUD-01 Blossom auth: kind 24242 with tags t=upload, expiration, x=sha256 (servers reject NIP-98 kind 27235; require human-readable content) - Add Content-Type fallback: try application/zip then image/png when application/octet-stream is rejected (415/400) - Accept 200 OK JSON blob descriptor: parse response and use 'url' field when present (blossom.primal.net, nostr.media return JSON not blossom://) - Add nip98 and base64 deps for auth; add bitcoin_hashes for payload hash Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7ce2b03 commit e926ffd

4 files changed

Lines changed: 111 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ path = "src/main.rs"
2222
[dependencies]
2323
anyhow = "1.0.99"
2424
clap = { version = "4.5.46", features = ["derive"] }
25-
nostr-sdk = { version = "0.43.0", features = ["nip06", "nip44", "nip59"] }
25+
nostr-sdk = { version = "0.43.0", features = ["nip06", "nip44", "nip59", "nip98"] }
2626
serde = "1.0.219"
2727
serde_json = "1.0.143"
2828
tokio = { version = "1.47.1", features = ["full"] }
@@ -50,6 +50,8 @@ dirs = "6.0.0"
5050
chacha20poly1305 = "0.10.1"
5151
rand_core = "0.6.4"
5252
bitcoin = "0.32.2"
53+
bitcoin_hashes = { version = "0.14", default-features = false }
54+
base64 = "0.22"
5355

5456
[package.metadata.release]
5557
# (Default: true) Set to false to prevent automatically running `cargo publish`.

src/cli/send_admin_dm_attach.rs

Lines changed: 105 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ use std::fs;
22
use std::path::PathBuf;
33

44
use anyhow::Result;
5+
use base64::engine::general_purpose::STANDARD as BASE64;
6+
use base64::Engine;
7+
use bitcoin_hashes::sha256::Hash as Sha256Hash;
8+
use bitcoin_hashes::Hash;
59
use chacha20poly1305::aead::{Aead, KeyInit};
610
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
711
use nostr_sdk::prelude::*;
@@ -38,7 +42,7 @@ fn derive_shared_key(trade_keys: &Keys, admin_pubkey: &PublicKey) -> [u8; 32] {
3842
.xonly()
3943
.expect("failed to get x-only public key for admin");
4044
let secp_pk = SecpPublicKey::from_x_only_public_key(xonly, Parity::Even);
41-
let mut point_bytes = shared_secret_point(&secp_pk, &sk).as_slice().to_vec();
45+
let mut point_bytes = shared_secret_point(&secp_pk, sk).as_slice().to_vec();
4246
point_bytes.resize(32, 0);
4347
point_bytes
4448
.try_into()
@@ -78,39 +82,113 @@ fn encrypt_blob(shared_key: [u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Stri
7882
Ok((blob, nonce_hex))
7983
}
8084

81-
async fn upload_to_blossom(encrypted_blob: Vec<u8>) -> Result<String> {
85+
/// Upload encrypted blob to a Blossom server.
86+
/// Blossom BUD-01 requires kind 24242 with: content (human-readable), tags ["t","upload"],
87+
/// ["expiration", "<future unix ts>"], ["x", "<sha256 hex>"].
88+
async fn upload_to_blossom(trade_keys: &Keys, encrypted_blob: Vec<u8>) -> Result<String> {
8289
use reqwest::StatusCode;
8390

8491
let client = reqwest::Client::new();
92+
let payload_hash = Sha256Hash::hash(&encrypted_blob);
93+
let payload_hex = payload_hash.to_string();
94+
95+
// Expiration: 1 hour from now (BUD-01 requires expiration in the future)
96+
let expiration = Timestamp::from(Timestamp::now().as_u64() + 3600);
8597

8698
for server in BLOSSOM_SERVERS {
87-
let url = format!("{}/upload", server.trim_end_matches('/'));
88-
let res = client
89-
.put(&url)
90-
.header("Content-Type", "application/octet-stream")
91-
.body(encrypted_blob.clone())
92-
.send()
93-
.await;
94-
95-
match res {
96-
Ok(resp) if resp.status() == StatusCode::OK => {
97-
let body = resp
98-
.text()
99-
.await
100-
.map_err(|e| anyhow::anyhow!("failed to read blossom response: {e}"))?;
101-
if body.trim().starts_with("blossom://") {
102-
return Ok(body.trim().to_string());
103-
}
104-
}
105-
Ok(resp) => {
106-
eprintln!(
107-
"Blossom upload failed on {server} with status {}",
108-
resp.status()
109-
);
99+
let url_str = format!("{}/upload", server.trim_end_matches('/'));
100+
let upload_url = match Url::parse(&url_str) {
101+
Ok(u) => u,
102+
Err(e) => {
103+
eprintln!("Blossom invalid URL {url_str}: {e}");
104+
continue;
110105
}
106+
};
107+
108+
let normalized_url_str = upload_url.as_str();
109+
110+
// BUD-01: kind 24242, content human-readable, tags: t=upload, expiration, x=sha256
111+
let tags = [
112+
Tag::hashtag("upload"), // ["t", "upload"]
113+
Tag::expiration(expiration),
114+
Tag::custom(TagKind::x(), [payload_hex.clone()]),
115+
];
116+
let event = match EventBuilder::new(Kind::BlossomAuth, "Upload Blob")
117+
.tags(tags)
118+
.sign_with_keys(trade_keys)
119+
{
120+
Ok(e) => e,
111121
Err(e) => {
112-
eprintln!("Blossom upload error on {server}: {e}");
122+
eprintln!("Blossom auth event build failed for {server}: {e}");
123+
continue;
124+
}
125+
};
126+
let auth_header = format!("Nostr {}", BASE64.encode(event.as_json()));
127+
128+
// Many Blossom servers reject application/octet-stream. Try application/zip first, then image/png.
129+
let content_types = ["application/zip", "image/png"];
130+
let mut last_status = None;
131+
let mut last_error_body = String::new();
132+
133+
for content_type in content_types {
134+
let res = client
135+
.put(normalized_url_str)
136+
.header("Content-Type", content_type)
137+
.header("Authorization", &auth_header)
138+
.body(encrypted_blob.clone())
139+
.send()
140+
.await;
141+
142+
match res {
143+
Ok(resp) if resp.status() == StatusCode::OK => {
144+
let body = resp
145+
.text()
146+
.await
147+
.map_err(|e| anyhow::anyhow!("failed to read blossom response: {e}"))?;
148+
let body = body.trim();
149+
// BUD-02: server may return JSON blob descriptor with "url" field
150+
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
151+
if let Some(url) = v.get("url").and_then(|u| u.as_str()) {
152+
return Ok(url.to_string());
153+
}
154+
}
155+
if body.starts_with("blossom://") {
156+
return Ok(body.to_string());
157+
}
158+
last_status = Some(StatusCode::OK);
159+
last_error_body = body.to_string();
160+
break;
161+
}
162+
Ok(resp) => {
163+
let status = resp.status();
164+
last_status = Some(status);
165+
last_error_body = resp.text().await.unwrap_or_default();
166+
// If rejected for content type, try next; otherwise report and break
167+
if status != StatusCode::UNSUPPORTED_MEDIA_TYPE
168+
&& status != StatusCode::BAD_REQUEST
169+
{
170+
break;
171+
}
172+
}
173+
Err(e) => {
174+
last_status = None;
175+
last_error_body = e.to_string();
176+
break;
177+
}
178+
}
179+
}
180+
181+
if let Some(status) = last_status {
182+
let status_text = status.canonical_reason().unwrap_or("Unknown");
183+
eprintln!(
184+
"Blossom upload failed on {server} with status {} {}",
185+
status, status_text
186+
);
187+
if !last_error_body.is_empty() && last_error_body.len() < 500 {
188+
eprintln!(" Response body: {}", last_error_body);
113189
}
190+
} else {
191+
eprintln!("Blossom upload error on {server}: {}", last_error_body);
114192
}
115193
}
116194

@@ -185,7 +263,7 @@ pub async fn execute_send_admin_dm_attach(
185263
let shared_key = derive_shared_key(&trade_keys, &receiver);
186264
let (encrypted_blob, nonce_hex) = encrypt_blob(shared_key, &file_bytes)?;
187265
let encrypted_size = encrypted_blob.len();
188-
let blossom_url = upload_to_blossom(encrypted_blob).await?;
266+
let blossom_url = upload_to_blossom(&trade_keys, encrypted_blob).await?;
189267

190268
let filename = file_path
191269
.file_name()

src/util/misc.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub fn uppercase_first(s: &str) -> String {
1010

1111
pub fn get_mcli_path() -> String {
1212
let home_dir = dirs::home_dir().expect("Couldn't get home directory");
13-
let mcli_path = format!("{}/.mcliUserA", home_dir.display());
13+
let mcli_path = format!("{}/.mcliUserB", home_dir.display());
1414
if !Path::new(&mcli_path).exists() {
1515
match fs::create_dir(&mcli_path) {
1616
Ok(_) => println!("Directory {} created.", mcli_path),

0 commit comments

Comments
 (0)