caracal 0.2.0

Nostr client for Gemini
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::channel;

use std::ops::DerefMut;

use standard_paths::{LocationType, StandardPaths};
use windmark_titanesque::{context::HookContext, router::Router};

use nostr_sdk::prelude::*;

use openssl::hash::MessageDigest;

use crate::events::{handle_nostr_notifications, user_subscribe};
use crate::storage::Storage;
use crate::user::{CaracalUser, USERS};
use crate::nostru;

#[derive(Default)]
pub struct Hookstr {
    data_path: PathBuf,
    identities_path: PathBuf,
    visits: usize,
    users: HashMap<String, CaracalUser>,
}

pub const DEFAULT_RELAYS: &[&str] = &[
    "wss://nostr.bitcoiner.social",
    "wss://nos.lol",
    "wss://relay.damus.io",
    "wss://offchain.pub",
];

impl Hookstr {
    pub async fn lookup_client(
        &mut self,
        fingerprint: String,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        #[allow(static_mut_refs)]
        unsafe {
            let us = USERS.deref_mut();

            if let Some(user) = us.get_mut(&fingerprint) {
                if let Ok(_msg) = user.rx.try_recv() {
                    user_subscribe(user).await;
                }

                return Err(Box::from("User already exists"));
            }
        }

        let i_path = self.identities_path.join(fingerprint.clone());

        let Ok(_) = std::fs::create_dir_all(i_path.clone()) else {
            return Err(Box::from("Cannot create directory for identity"));
        };

        /* nostr-lmdb database path */
        let lmdbp = i_path.clone().join("nostr-lmdb");

        /* user's database path */
        let storep = i_path.clone().join("userdb");

        let Ok(database) = NostrLMDB::open(lmdbp.display().to_string()) else {
            return Err(Box::from("Cannot open nostr database"));
        };

        let Ok(storage) = Storage::new(storep.as_path()) else {
            return Err(Box::from("Cannot create storage"));
        };

        storage.create_following_db()?;
        storage.create_drafts_db()?;
        storage.create_keys_db()?;
        storage.create_hashtags_db()?;

        let keys = {
            let mut rtxn = storage.get_read_txn()?;
            match storage.get_default_keys(&mut rtxn) {
                Ok(def_keys) => def_keys,
                Err(_err) => {
                    storage.create_default_keys()?
                }
            }
        };

        let opts = ClientOptions::default()
            .autoconnect(true)
            .sleep_when_idle(SleepWhenIdle::Enabled {
                timeout: Duration::from_secs(90),
            })
            .gossip(true);

        let client = Client::builder()
            .opts(opts)
            .signer(keys.clone())
            .database(database)
            .build();

        for rurl in DEFAULT_RELAYS {
            client.add_relay(rurl.to_string()).await?;
        }

        client.connect().await;

        if let Err(err) = nostru::advertise_relay_list(&client).await {
            eprintln!("Failed to advertise relay list: {err}")
        }

        #[allow(static_mut_refs)]
        unsafe {
            let us = USERS.deref_mut();
            let (tx, rx) = channel(16);

            let client = Arc::new(client);
            tokio::spawn(handle_nostr_notifications(client.clone()));

            let user = CaracalUser {
                client,
                upload_keys: Keys::generate(),
                signer: Arc::new(keys),
                connect_uri: None,
                storage,
                tx,
                rx,
            };

            user_subscribe(&user).await;

            us.insert(fingerprint.clone(), user);
        }

        Ok(())
    }
}

fn fingerprint_from_hook(context: &HookContext) -> Option<String> {
    use std::fmt::Write;

    if let Some(cert) = &context.certificate {
        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,
        }
    } else {
        None
    }
}

#[async_trait::async_trait]
impl windmark_titanesque::module::AsyncModule for Hookstr {
    async fn on_attach(&mut self, _router: &mut Router) {
        let sp = StandardPaths::without_org(env!("CARGO_PKG_NAME"));

        self.data_path = sp
            .writable_location(LocationType::AppLocalDataLocation)
            .unwrap();

        self.identities_path = self.data_path.clone().join("identitites");

        let _ = std::fs::create_dir_all(self.identities_path.clone());
    }

    async fn on_pre_route(&mut self, context: HookContext) {
        if let Some(fp) = fingerprint_from_hook(&context) {
            let _ = self.lookup_client(fp).await;
        }

        self.visits += 1;
    }
}