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