caracal 0.2.0

Nostr client for Gemini
use crate::Url;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::path::PathBuf;

use reqwest::{Client, multipart};

use tokio::fs;

use bitcoin::hashes::Hash;
use bitcoin::hashes::sha256::Hash as Sha256Hash;

use rand::seq::SliceRandom;

use crate::nips::nip98;

#[derive(Debug, Deserialize)]
pub struct ServerConfig {
    pub api_url: String,
    pub download_url: String,
    pub delegated_to_url: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Nip94Event {
    tags: Vec<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct UploadResponse {
    status: String,
    nip94_event: Nip94Event,
}

const NIP96_SERVERS: &[&str] = &[
    "https://nostrcheck.me",
    "https://nostrage.com",
    "https://sove.rent",
    "https://nostr.build",
    "https://files.sovbit.host",
    "https://void.cat",
    "https://nostpic.com",
    "https://nostr.onch.services",
];

fn random_server_url() -> Url {
    if let Some(urls) = NIP96_SERVERS.choose(&mut rand::thread_rng()) {
        Url::parse(urls).unwrap()
    } else {
        Url::parse(NIP96_SERVERS[0]).unwrap()
    }
}

pub async fn get_server_desc(
    server_url: Url,
) -> Result<ServerConfig, Box<dyn Error>> {
    /* Get the nip96.json file and return the parsed JSON */

    let client = Client::new();
    let json_url = server_url.join("/.well-known/nostr/nip96.json")?;

    let response = client.get(json_url).send().await?;

    if let Ok(config) = response.json::<ServerConfig>().await {
        Ok(config)
    } else {
        Err(Box::from("Cannot get server description"))
    }
}

pub async fn get_api_url_for(server_url: Url) -> Result<Url, Box<dyn Error>> {
    if let Ok(desc) = get_server_desc(server_url.clone()).await {
        if let Ok(url) = Url::parse(desc.api_url.as_str()) {
            Ok(url)
        } else {
            Err(Box::from("Invalid URL"))
        }
    } else {
        Err(Box::from("No desc"))
    }
}

pub async fn upload_file(
    file_path: PathBuf,
    mime_type: String,
    api_url: Url,
    keys: &Keys,
) -> Result<Url, Box<dyn Error>> {
    let client = Client::new();
    let contents = fs::read(file_path).await?;
    let payload = Sha256Hash::hash(&contents[..]);

    let nip98_auth = nip98::build_nip98_auth_event_b64(
        keys,
        api_url.clone(),
        payload,
    )
    .await;

    let some_file = multipart::Part::bytes(contents)
        .file_name("test.jpg")
        .mime_str(mime_type.as_str())?;

    let form = multipart::Form::new().part("file", some_file);

    let response = client
        .post(api_url)
        .header("Authorization", format!("Nostr {}", nip98_auth).as_str())
        .multipart(form)
        .send()
        .await?;

    if let Ok(resp) = response.json::<UploadResponse>().await {
        for tag in resp.nip94_event.tags.iter() {
            match tag[0].as_str() {
                "url" => {
                    return Ok(Url::parse(tag[1].as_str()).unwrap());
                }
                _ => continue,
            }
        }
        Err(Box::from("Uploaded file URL not found"))
    } else {
        Err(Box::from("Cannot decode upload response"))
    }
}

pub async fn upload_file_data(
    server_url: Option<Url>,
    contents: Vec<u8>,
    mime_type: String,
    keys: &Keys,
) -> Result<Url, Box<dyn Error>> {
    /* Use the passed server URL or pick a random server */
    let base_url = match server_url {
        Some(url) => url,
        None => random_server_url(),
    };

    let Ok(api_url) = get_api_url_for(base_url.clone()).await else {
        return Err(Box::from("Cannot determine the API URL"));
    };

    let client = Client::new();
    let payload = Sha256Hash::hash(&contents[..]);

    let nip98_auth = nip98::build_nip98_auth_event_b64(
        keys,
        api_url.clone(),
        payload,
    )
    .await;

    let some_file = multipart::Part::bytes(contents)
        .file_name("filename")
        .mime_str(mime_type.as_str())?;

    let form = multipart::Form::new().part("file", some_file);

    let response = client
        .post(api_url)
        .header("Authorization", format!("Nostr {}", nip98_auth).as_str())
        .multipart(form)
        .send()
        .await?;

    if let Ok(resp) = response.json::<UploadResponse>().await {
        for tag in resp.nip94_event.tags.iter() {
            match tag[0].as_str() {
                "url" => {
                    return Ok(Url::parse(tag[1].as_str()).unwrap());
                }
                _ => continue,
            }
        }
        Err(Box::from("Uploaded file URL not found"))
    } else {
        Err(Box::from("Cannot decode upload response"))
    }
}