media_upload.rs (12383B)
1 use std::path::PathBuf; 2 3 use base64::{prelude::BASE64_URL_SAFE, Engine}; 4 use ehttp::Request; 5 use nostrdb::{Note, NoteBuilder}; 6 use notedeck::SupportedMimeType; 7 use poll_promise::Promise; 8 use sha2::{Digest, Sha256}; 9 use url::Url; 10 11 use crate::{images::fetch_binary_from_disk, Error}; 12 13 pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); 14 const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; 15 16 fn get_upload_url(nip96_url: Url) -> Promise<Result<String, Error>> { 17 let request = Request::get(nip96_url); 18 let (sender, promise) = Promise::new(); 19 20 ehttp::fetch(request, move |response| { 21 let result = match response { 22 Ok(resp) => { 23 if resp.status == 200 { 24 if let Some(text) = resp.text() { 25 get_api_url_from_json(text) 26 } else { 27 Err(Error::Generic( 28 "ehttp::Response payload is not text".to_owned(), 29 )) 30 } 31 } else { 32 Err(Error::Generic(format!( 33 "ehttp::Response status: {}", 34 resp.status 35 ))) 36 } 37 } 38 Err(e) => Err(Error::Generic(e)), 39 }; 40 41 sender.send(result); 42 }); 43 44 promise 45 } 46 47 fn get_api_url_from_json(json: &str) -> Result<String, Error> { 48 match serde_json::from_str::<serde_json::Value>(json) { 49 Ok(json) => { 50 if let Some(url) = json 51 .get("api_url") 52 .and_then(|url| url.as_str()) 53 .map(|url| url.to_string()) 54 { 55 Ok(url) 56 } else { 57 Err(Error::Generic( 58 "api_url key not found in ehttp::Response".to_owned(), 59 )) 60 } 61 } 62 Err(e) => Err(Error::Generic(e.to_string())), 63 } 64 } 65 66 fn get_upload_url_from_provider(mut provider_url: Url) -> Promise<Result<String, Error>> { 67 provider_url.set_path(NIP96_WELL_KNOWN); 68 get_upload_url(provider_url) 69 } 70 71 pub fn get_nostr_build_upload_url() -> Promise<Result<String, Error>> { 72 get_upload_url_from_provider(NOSTR_BUILD_URL()) 73 } 74 75 fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note { 76 NoteBuilder::new() 77 .kind(27235) 78 .start_tag() 79 .tag_str("u") 80 .tag_str(&upload_url) 81 .start_tag() 82 .tag_str("method") 83 .tag_str("POST") 84 .start_tag() 85 .tag_str("payload") 86 .tag_str(&payload_hash) 87 .sign(seckey) 88 .build() 89 .expect("build note") 90 } 91 92 fn create_nip96_request( 93 upload_url: &str, 94 media_path: MediaPath, 95 file_contents: Vec<u8>, 96 nip98_base64: &str, 97 ) -> ehttp::Request { 98 let boundary = "----boundary"; 99 100 let mut body = format!( 101 "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", 102 boundary, media_path.file_name, media_path.media_type.to_mime() 103 ) 104 .into_bytes(); 105 body.extend(file_contents); 106 body.extend(format!("\r\n--{}--\r\n", boundary).as_bytes()); 107 108 let headers = ehttp::Headers::new(&[ 109 ( 110 "Content-Type", 111 format!("multipart/form-data; boundary={boundary}").as_str(), 112 ), 113 ("Authorization", format!("Nostr {nip98_base64}").as_str()), 114 ]); 115 116 Request { 117 method: "POST".to_string(), 118 url: upload_url.to_string(), 119 headers, 120 body, 121 } 122 } 123 124 fn sha256_hex(contents: &Vec<u8>) -> String { 125 let mut hasher = Sha256::new(); 126 hasher.update(contents); 127 let hash = hasher.finalize(); 128 hex::encode(hash) 129 } 130 131 pub fn nip96_upload( 132 seckey: [u8; 32], 133 upload_url: String, 134 media_path: MediaPath, 135 ) -> Promise<Result<Nip94Event, Error>> { 136 let bytes_res = fetch_binary_from_disk(media_path.full_path.clone()); 137 138 let file_bytes = match bytes_res { 139 Ok(bytes) => bytes, 140 Err(e) => { 141 return Promise::from_ready(Err(Error::Generic(format!( 142 "could not read contents of file to upload: {e}" 143 )))) 144 } 145 }; 146 147 internal_nip96_upload(seckey, upload_url, media_path, file_bytes) 148 } 149 150 pub fn nostrbuild_nip96_upload( 151 seckey: [u8; 32], 152 media_path: MediaPath, 153 ) -> Promise<Result<Nip94Event, Error>> { 154 let (sender, promise) = Promise::new(); 155 std::thread::spawn(move || { 156 let upload_url = match get_nostr_build_upload_url().block_and_take() { 157 Ok(url) => url, 158 Err(e) => { 159 sender.send(Err(Error::Generic(format!( 160 "could not get nostrbuild upload url: {e}" 161 )))); 162 return; 163 } 164 }; 165 166 let res = nip96_upload(seckey, upload_url, media_path).block_and_take(); 167 sender.send(res); 168 }); 169 promise 170 } 171 172 fn internal_nip96_upload( 173 seckey: [u8; 32], 174 upload_url: String, 175 media_path: MediaPath, 176 file_contents: Vec<u8>, 177 ) -> Promise<Result<Nip94Event, Error>> { 178 let file_hash = sha256_hex(&file_contents); 179 let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash); 180 181 let nip98_base64 = match nip98_note.json() { 182 Ok(json) => BASE64_URL_SAFE.encode(json), 183 Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))), 184 }; 185 186 let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64); 187 188 let (sender, promise) = Promise::new(); 189 190 ehttp::fetch(request, move |response| { 191 let maybe_uploaded_media = match response { 192 Ok(response) => { 193 if response.ok { 194 match String::from_utf8(response.bytes.clone()) { 195 Ok(str_response) => find_nip94_ev_in_json(str_response), 196 Err(e) => Err(Error::Generic(e.to_string())), 197 } 198 } else { 199 Err(Error::Generic(format!( 200 "ehttp Response was unsuccessful. Code {} with message: {}", 201 response.status, response.status_text 202 ))) 203 } 204 } 205 Err(e) => Err(Error::Generic(e)), 206 }; 207 208 sender.send(maybe_uploaded_media); 209 }); 210 211 promise 212 } 213 214 fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> { 215 match serde_json::from_str::<serde_json::Value>(&json) { 216 Ok(v) => { 217 let tags = v["nip94_event"]["tags"].clone(); 218 let content = v["nip94_event"]["content"] 219 .as_str() 220 .unwrap_or_default() 221 .to_string(); 222 match serde_json::from_value::<Vec<Vec<String>>>(tags) { 223 Ok(tags) => Nip94Event::from_tags_and_content(tags, content) 224 .map_err(|e| Error::Generic(e.to_owned())), 225 Err(e) => Err(Error::Generic(e.to_string())), 226 } 227 } 228 Err(e) => Err(Error::Generic(e.to_string())), 229 } 230 } 231 232 #[derive(Debug)] 233 pub struct MediaPath { 234 full_path: PathBuf, 235 file_name: String, 236 media_type: SupportedMimeType, 237 } 238 239 impl MediaPath { 240 pub fn new(path: PathBuf) -> Result<Self, Error> { 241 if let Some(ex) = path.extension().and_then(|f| f.to_str()) { 242 let media_type = SupportedMimeType::from_extension(ex)?; 243 let file_name = path 244 .file_name() 245 .and_then(|name| name.to_str()) 246 .unwrap_or(&format!("file.{}", ex)) 247 .to_owned(); 248 249 Ok(MediaPath { 250 full_path: path, 251 file_name, 252 media_type, 253 }) 254 } else { 255 Err(Error::Generic(format!( 256 "{:?} does not have an extension", 257 path 258 ))) 259 } 260 } 261 } 262 263 #[derive(Clone, Debug, serde::Deserialize)] 264 pub struct Nip94Event { 265 pub url: String, 266 pub ox: Option<String>, 267 pub x: Option<String>, 268 pub media_type: Option<String>, 269 pub dimensions: Option<(u32, u32)>, 270 pub blurhash: Option<String>, 271 pub thumb: Option<String>, 272 pub content: String, 273 } 274 275 impl Nip94Event { 276 pub fn new(url: String, width: u32, height: u32) -> Self { 277 Self { 278 url, 279 ox: None, 280 x: None, 281 media_type: None, 282 dimensions: Some((width, height)), 283 blurhash: None, 284 thumb: None, 285 content: String::new(), 286 } 287 } 288 } 289 290 const URL: &str = "url"; 291 const OX: &str = "ox"; 292 const X: &str = "x"; 293 const M: &str = "m"; 294 const DIM: &str = "dim"; 295 const BLURHASH: &str = "blurhash"; 296 const THUMB: &str = "thumb"; 297 298 impl Nip94Event { 299 fn from_tags_and_content( 300 tags: Vec<Vec<String>>, 301 content: String, 302 ) -> Result<Self, &'static str> { 303 let mut url = None; 304 let mut ox = None; 305 let mut x = None; 306 let mut media_type = None; 307 let mut dimensions = None; 308 let mut blurhash = None; 309 let mut thumb = None; 310 311 for tag in tags { 312 match tag.as_slice() { 313 [key, value] if key == URL => url = Some(value.to_string()), 314 [key, value] if key == OX => ox = Some(value.to_string()), 315 [key, value] if key == X => x = Some(value.to_string()), 316 [key, value] if key == M => media_type = Some(value.to_string()), 317 [key, value] if key == DIM => { 318 if let Some((w, h)) = value.split_once('x') { 319 if let (Ok(w), Ok(h)) = (w.parse::<u32>(), h.parse::<u32>()) { 320 dimensions = Some((w, h)); 321 } 322 } 323 } 324 [key, value] if key == BLURHASH => blurhash = Some(value.to_string()), 325 [key, value] if key == THUMB => thumb = Some(value.to_string()), 326 _ => {} 327 } 328 } 329 330 Ok(Self { 331 url: url.ok_or("Missing url")?, 332 ox, 333 x, 334 media_type, 335 dimensions, 336 blurhash, 337 thumb, 338 content, 339 }) 340 } 341 } 342 343 #[cfg(test)] 344 mod tests { 345 use std::{fs, path::PathBuf, str::FromStr}; 346 347 use enostr::FullKeypair; 348 349 use crate::media_upload::{ 350 get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL, 351 }; 352 353 use super::internal_nip96_upload; 354 355 #[test] 356 fn test_nostrbuild_upload_url() { 357 let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); 358 359 let url = promise.block_until_ready(); 360 361 assert!(url.is_ok()); 362 } 363 364 #[test] 365 #[ignore] // this test should not run automatically since it sends data to a real server 366 fn test_internal_nip96() { 367 // just a random image to test image upload 368 let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap(); 369 let media_path = MediaPath::new(file_path).unwrap(); 370 let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); 371 let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); 372 let kp = FullKeypair::generate(); 373 println!("Using pubkey: {:?}", kp.pubkey); 374 375 if let Ok(upload_url) = promise.block_until_ready() { 376 let promise = internal_nip96_upload( 377 kp.secret_key.secret_bytes(), 378 upload_url.to_string(), 379 media_path, 380 img_bytes.to_vec(), 381 ); 382 let res = promise.block_until_ready(); 383 assert!(res.is_ok()) 384 } else { 385 panic!() 386 } 387 } 388 389 #[tokio::test] 390 #[ignore] // this test should not run automatically since it sends data to a real server 391 async fn test_nostrbuild_nip96() { 392 // just a random image to test image upload 393 let file_path = 394 fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap()) 395 .unwrap(); 396 let media_path = MediaPath::new(file_path).unwrap(); 397 let kp = FullKeypair::generate(); 398 println!("Using pubkey: {:?}", kp.pubkey); 399 400 let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path); 401 402 let out = promise.block_and_take(); 403 assert!(out.is_ok()); 404 } 405 }