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