notedeck

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

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 }