notedeck

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

media_upload.rs (12383B)


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