diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs index c7860c9..923faa1 100644 --- a/crates/notedeck_columns/src/lib.rs +++ b/crates/notedeck_columns/src/lib.rs @@ -18,6 +18,7 @@ mod frame_history; mod images; mod key_parsing; pub mod login_manager; +mod media_upload; mod multi_subscriber; mod nav; mod post; diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs new file mode 100644 index 0000000..4409066 --- /dev/null +++ b/crates/notedeck_columns/src/media_upload.rs @@ -0,0 +1,447 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use base64::{prelude::BASE64_URL_SAFE, Engine}; +use ehttp::Request; +use nostrdb::{Note, NoteBuilder}; +use poll_promise::Promise; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::{images::fetch_binary_from_disk, Error}; + +pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); +const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; + +fn get_upload_url(nip96_url: Url) -> Promise> { + let request = Request::get(nip96_url); + let (sender, promise) = Promise::new(); + + ehttp::fetch(request, move |response| { + let result = match response { + Ok(resp) => { + if resp.status == 200 { + if let Some(text) = resp.text() { + get_api_url_from_json(text) + } else { + Err(Error::Generic( + "ehttp::Response payload is not text".to_owned(), + )) + } + } else { + Err(Error::Generic(format!( + "ehttp::Response status: {}", + resp.status + ))) + } + } + Err(e) => Err(Error::Generic(e)), + }; + + sender.send(result); + }); + + promise +} + +fn get_api_url_from_json(json: &str) -> Result { + match serde_json::from_str::(json) { + Ok(json) => { + if let Some(url) = json + .get("api_url") + .and_then(|url| url.as_str()) + .map(|url| url.to_string()) + { + Ok(url) + } else { + Err(Error::Generic( + "api_url key not found in ehttp::Response".to_owned(), + )) + } + } + Err(e) => Err(Error::Generic(e.to_string())), + } +} + +fn get_upload_url_from_provider(mut provider_url: Url) -> Promise> { + provider_url.set_path(NIP96_WELL_KNOWN); + get_upload_url(provider_url) +} + +pub fn get_nostr_build_upload_url() -> Promise> { + get_upload_url_from_provider(NOSTR_BUILD_URL()) +} + +fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note { + NoteBuilder::new() + .kind(27235) + .start_tag() + .tag_str("u") + .tag_str(&upload_url) + .start_tag() + .tag_str("method") + .tag_str("POST") + .start_tag() + .tag_str("payload") + .tag_str(&payload_hash) + .sign(seckey) + .build() + .expect("build note") +} + +fn create_nip96_request( + upload_url: &str, + media_path: MediaPath, + file_contents: Vec, + nip98_base64: &str, +) -> ehttp::Request { + let boundary = "----boundary"; + + let mut body = format!( + "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", + boundary, media_path.file_name, media_path.media_type.to_mime() + ) + .into_bytes(); + body.extend(file_contents); + body.extend(format!("\r\n--{}--\r\n", boundary).as_bytes()); + + let headers = { + let mut map = BTreeMap::new(); + map.insert( + "Content-Type".to_owned(), + format!("multipart/form-data; boundary={boundary}"), + ); + map.insert("Authorization".to_owned(), format!("Nostr {nip98_base64}")); + map + }; + + Request { + method: "POST".to_string(), + url: upload_url.to_string(), + headers, + body: body.into(), + } +} + +fn sha256_hex(contents: &Vec) -> String { + let mut hasher = Sha256::new(); + hasher.update(contents); + let hash = hasher.finalize(); + hex::encode(hash) +} + +pub fn nip96_upload( + seckey: [u8; 32], + upload_url: String, + media_path: MediaPath, +) -> Promise> { + let bytes_res = fetch_binary_from_disk(media_path.full_path.clone()); + + let file_bytes = match bytes_res { + Ok(bytes) => bytes, + Err(e) => { + return Promise::from_ready(Err(Error::Generic(format!( + "could not read contents of file to upload: {e}" + )))) + } + }; + + internal_nip96_upload(seckey, upload_url, media_path, file_bytes) +} + +pub fn nostrbuild_nip96_upload( + seckey: [u8; 32], + media_path: MediaPath, +) -> Promise> { + let (sender, promise) = Promise::new(); + std::thread::spawn(move || { + let upload_url = match get_nostr_build_upload_url().block_and_take() { + Ok(url) => url, + Err(e) => { + sender.send(Err(Error::Generic(format!( + "could not get nostrbuild upload url: {e}" + )))); + return; + } + }; + + let res = nip96_upload(seckey, upload_url, media_path).block_and_take(); + sender.send(res); + }); + promise +} + +fn internal_nip96_upload( + seckey: [u8; 32], + upload_url: String, + media_path: MediaPath, + file_contents: Vec, +) -> Promise> { + let file_hash = sha256_hex(&file_contents); + let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash); + + let nip98_base64 = match nip98_note.json() { + Ok(json) => BASE64_URL_SAFE.encode(json), + Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))), + }; + + let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64); + + let (sender, promise) = Promise::new(); + + ehttp::fetch(request, move |response| { + let maybe_uploaded_media = match response { + Ok(response) => { + if response.ok { + match String::from_utf8(response.bytes.clone()) { + Ok(str_response) => find_nip94_ev_in_json(str_response), + Err(e) => Err(Error::Generic(e.to_string())), + } + } else { + Err(Error::Generic(format!( + "ehttp Response was unsuccessful. Code {} with message: {}", + response.status, response.status_text + ))) + } + } + Err(e) => Err(Error::Generic(e)), + }; + + sender.send(maybe_uploaded_media); + }); + + promise +} + +fn find_nip94_ev_in_json(json: String) -> Result { + match serde_json::from_str::(&json) { + Ok(v) => { + let tags = v["nip94_event"]["tags"].clone(); + let content = v["nip94_event"]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + match serde_json::from_value::>>(tags) { + Ok(tags) => Nip94Event::from_tags_and_content(tags, content) + .map_err(|e| Error::Generic(e.to_owned())), + Err(e) => Err(Error::Generic(e.to_string())), + } + } + Err(e) => Err(Error::Generic(e.to_string())), + } +} + +#[derive(Debug)] +pub struct MediaPath { + full_path: PathBuf, + file_name: String, + media_type: SupportedMediaType, +} + +impl MediaPath { + pub fn new(path: PathBuf) -> Result { + if let Some(ex) = path.extension().and_then(|f| f.to_str()) { + let media_type = SupportedMediaType::from_extension(ex)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&format!("file.{}", ex)) + .to_owned(); + + Ok(MediaPath { + full_path: path, + file_name, + media_type, + }) + } else { + Err(Error::Generic(format!( + "{:?} does not have an extension", + path + ))) + } + } +} + +#[derive(Debug)] +pub enum SupportedMediaType { + Png, + Jpeg, + Webp, +} + +impl SupportedMediaType { + pub fn mime_extension(&self) -> &str { + match &self { + SupportedMediaType::Png => "png", + SupportedMediaType::Jpeg => "jpeg", + SupportedMediaType::Webp => "webp", + } + } + + pub fn to_mime(&self) -> String { + format!("{}/{}", self.mime_type(), self.mime_extension()) + } + + fn mime_type(&self) -> String { + match &self { + SupportedMediaType::Png | SupportedMediaType::Jpeg | SupportedMediaType::Webp => { + "image" + } + } + .to_string() + } + + fn from_extension(ext: &str) -> Result { + match ext.to_lowercase().as_str() { + "jpeg" | "jpg" => Ok(SupportedMediaType::Jpeg), + "png" => Ok(SupportedMediaType::Png), + "webp" => Ok(SupportedMediaType::Webp), + unsupported_type => Err(Error::Generic(format!( + "{unsupported_type} is not a valid file type to upload." + ))), + } + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct Nip94Event { + pub url: String, + pub ox: Option, + pub x: Option, + pub media_type: Option, + pub dimensions: Option<(u32, u32)>, + pub blurhash: Option, + pub thumb: Option, + pub content: String, +} + +impl Nip94Event { + pub fn new(url: String, width: u32, height: u32) -> Self { + Self { + url, + ox: None, + x: None, + media_type: None, + dimensions: Some((width, height)), + blurhash: None, + thumb: None, + content: String::new(), + } + } +} + +const URL: &str = "url"; +const OX: &str = "ox"; +const X: &str = "x"; +const M: &str = "m"; +const DIM: &str = "dim"; +const BLURHASH: &str = "blurhash"; +const THUMB: &str = "thumb"; + +impl Nip94Event { + fn from_tags_and_content( + tags: Vec>, + content: String, + ) -> Result { + let mut url = None; + let mut ox = None; + let mut x = None; + let mut media_type = None; + let mut dimensions = None; + let mut blurhash = None; + let mut thumb = None; + + for tag in tags { + match tag.as_slice() { + [key, value] if key == URL => url = Some(value.to_string()), + [key, value] if key == OX => ox = Some(value.to_string()), + [key, value] if key == X => x = Some(value.to_string()), + [key, value] if key == M => media_type = Some(value.to_string()), + [key, value] if key == DIM => { + if let Some((w, h)) = value.split_once('x') { + if let (Ok(w), Ok(h)) = (w.parse::(), h.parse::()) { + dimensions = Some((w, h)); + } + } + } + [key, value] if key == BLURHASH => blurhash = Some(value.to_string()), + [key, value] if key == THUMB => thumb = Some(value.to_string()), + _ => {} + } + } + + Ok(Self { + url: url.ok_or("Missing url")?, + ox, + x, + media_type, + dimensions, + blurhash, + thumb, + content, + }) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf, str::FromStr}; + + use enostr::FullKeypair; + + use crate::media_upload::{ + get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL, + }; + + use super::internal_nip96_upload; + + #[test] + fn test_nostrbuild_upload_url() { + let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); + + let url = promise.block_until_ready(); + + assert!(url.is_ok()); + } + + #[test] + #[ignore] // this test should not run automatically since it sends data to a real server + fn test_internal_nip96() { + // just a random image to test image upload + let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap(); + let media_path = MediaPath::new(file_path).unwrap(); + let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); + let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); + let kp = FullKeypair::generate(); + println!("Using pubkey: {:?}", kp.pubkey); + + if let Ok(upload_url) = promise.block_until_ready() { + let promise = internal_nip96_upload( + kp.secret_key.secret_bytes(), + upload_url.to_string(), + media_path, + img_bytes.to_vec(), + ); + let res = promise.block_until_ready(); + assert!(res.is_ok()) + } else { + panic!() + } + } + + #[tokio::test] + #[ignore] // this test should not run automatically since it sends data to a real server + async fn test_nostrbuild_nip96() { + // just a random image to test image upload + let file_path = + fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap()) + .unwrap(); + let media_path = MediaPath::new(file_path).unwrap(); + let kp = FullKeypair::generate(); + println!("Using pubkey: {:?}", kp.pubkey); + + let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path); + + let out = promise.block_and_take(); + assert!(out.is_ok()); + } +}