notedeck

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

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 }