caracal 0.4.2

Nostr client for Gemini
use crate::Url;
use crate::nips::nip96;
use crate::relays::blossom_servers;
use gnostr_types::{ContentSegment, ShatteredContent};
use nostr::Keys;
use nostr_blossom::{bud02::BlobDescriptor, client::BlossomClient};
use nostr_sdk::prelude::*;
use openssl::hash::MessageDigest;
use openssl::x509::X509;
use rand::seq::SliceRandom;
use std::sync::Arc;
use windmark_titanesque::context::{RouteContext, TitanResource};

use itertools::Itertools;

pub fn decode_query(ctx: &RouteContext) -> Option<String> {
    ctx.url
        .query()
        .and_then(|url| urlencoding::decode(url).ok().map(|s| s.into()))
}

pub fn fingerprint(cert: &X509) -> Option<String> {
    use std::fmt::Write;

    match cert.digest(MessageDigest::sha256()) {
        Ok(dbytes) => {
            let digest: String =
                dbytes.iter().fold(String::new(), |mut out, x| {
                    let _ = write!(out, "{:02x}", x);
                    out
                });

            Some(digest)
        }
        Err(_error) => None,
    }
}

/// Upload a file received via the titan protocol to a random NIP-96 server
pub async fn titan96(
    titan_rsc: Option<TitanResource>,
    keys: &Keys,
) -> Option<Url> {
    if let Some(titan) = titan_rsc {
        (nip96::upload_file_data(None, titan.content, titan.mime, keys).await)
            .ok()
    } else {
        None
    }
}

/// Send a file received via titan to a blossom server
pub async fn titan_to_blossom(
    titan: TitanResource,
    signer: Arc<dyn NostrSigner>,
) -> Result<BlobDescriptor, nostr_blossom::error::Error> {
    let mime = tree_magic_mini::from_u8(&titan.content);

    // Pick a blossom server
    let servers = blossom_servers();
    let server_url = match servers.choose(&mut rand::thread_rng()) {
        Some(u) => u.to_owned(),
        None => Url::parse("https://blossom.nostr.build").unwrap(),
    };

    let blossom = BlossomClient::new(server_url);

    blossom
        .upload_blob(titan.content, Some(mime.into()), None, Some(&signer))
        .await
}

/// Extract the first words of the content of an event (only used for notes for now)
pub fn extract_first_words(event: &Event, max_words: usize) -> Option<String> {
    match event.kind {
        Kind::TextNote | Kind::LongFormTextNote => {
            let shattered = ShatteredContent::new(event.content.clone());
            let text = shattered
                .segments
                .iter()
                .filter_map(|seg| {
                    if let ContentSegment::Plain(span) = seg
                        && let Some(slice) = shattered.slice(span)
                        && !slice.is_empty()
                    {
                        Some(
                            slice
                                .lines()
                                .filter(|s| !s.is_empty())
                                .collect::<Vec<_>>()
                                .join("\n"),
                        )
                    } else {
                        None
                    }
                })
                .collect::<Vec<_>>()
                .join("\n");

            text.lines()
                .map(str::to_string)
                .next()
                .map(|s| s.split_whitespace().take(max_words).join(" "))
        }
        // Return None for anything else than a text note
        _ => None,
    }
}