notedeck

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

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 }