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