notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }