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