caracal 0.2.0

Nostr client for Gemini
mod contacts;
mod dm;
mod following;
mod hashtags;
mod inbox;
mod notes;
mod nsite;
mod polls;
mod profile;
mod remote_signer;
mod response;
mod search;

pub use response::*;

pub use contacts::contact_list_index;
pub use dm::{person_dm_conversation, person_send_dm};
pub use following::{follow, person_follow, person_unfollow};
pub use hashtags::{hashtags_index, notes_by_hashtag};
pub use inbox::inbox;
pub use notes::{
    draft_edit, draft_new, draft_post, draft_upload_file, note_react,
    note_reply, note_repost, note_thread, post_note,
};
pub use nsite::nsites_index;
pub use polls::{poll_details, poll_respond, polls_list};
pub use profile::{
    profile_edit, profile_index, profile_init_reset, profile_upload,
};
pub use remote_signer::{nostr_connect_change, nostr_connect_index};
pub use search::search_content;

use crate::aska::WindTemplate;
use crate::nostru;
use crate::rendering;
use crate::user::{CaracalUser, lookup_user, lookup_user_mut};
use crate::util::fingerprint;

use askama::Template;

use openssl::nid::Nid;
use windmark_titanesque::{context::RouteContext, response::Response};

use chrono::DateTime;
use nostr_sdk::prelude::*;
use std::time::Duration;

use mdiu::Gemtext;
use mdiu::{Block, Content, Level, Markup};

use openssl::x509::X509;

use rust_i18n::t;

use std::future::IntoFuture;

rust_i18n::i18n!("locales");

#[derive(Template, Clone, Copy)]
#[template(path = "index.gmi", escape = "txt")]
struct IndexTemplate {}

#[derive(Template, Clone, Copy)]
#[template(path = "index_nocert.gmi", escape = "txt")]
struct IndexNoCertTemplate {}

#[derive(Template)]
#[template(path = "feed.gmi", escape = "txt")]
pub struct FeedTemplate {
    b1: Block,
    edoc: Vec<Block>,
}

#[derive(Template)]
#[template(path = "person_index.gmi", escape = "txt")]
pub struct PersonIndexTemplate {
    npub: String,
    metadata: Option<Metadata>,
    notes_blocks: Option<Vec<Block>>,
}

#[derive(Template)]
#[template(path = "profile_index.gmi", escape = "txt")]
pub struct MyProfileTemplate {
    url: Url,
    npub: String,
    metadata: Option<Metadata>,
}

fn gem(blocks: &[Block]) -> String {
    <Gemtext>::markup(blocks)
}

/// Formats a [`Timestamp`] to a human format
pub fn humanize_ts(ts: &Timestamp) -> String {
    if let Ok(dt) =
        DateTime::parse_from_rfc3339(ts.to_human_datetime().as_str())
    {
        format!("{}", dt.format("%a %b %e %Y, %T"))
    } else {
        String::from("Invalid date")
    }
}

pub async fn guardian<R>(
    ctx: RouteContext,
    mut handler: impl FnMut(RouteContext, &'static mut CaracalUser) -> R
    + Send
    + Sync
    + 'static,
) -> Response
where
    R: IntoFuture<Output = Response> + Send + 'static,
{
    if let Some(cert) = &ctx.certificate {
        let Some(fingerprint) = fingerprint(cert) else {
            return Response::temporary_failure("Cannot get fingerprint");
        };

        let Some(user) = lookup_user_mut(fingerprint) else {
            return Response::temporary_failure("Cannot find user");
        };

        let subject = cert.subject_name();

        if let Some(org) = subject.entries_by_nid(Nid::ORGANIZATIONNAME).next()
        {
            let Ok(org) = org.data().as_utf8() else {
                return Response::temporary_failure(
                    "Failed to retrieve organization field in cert",
                );
            };

            // If the cert's "Organization" field = nostr, use Nostr Connect
            if org.to_string().to_lowercase() == "nostr" {
                if user.connect_uri.is_none() {
                    nostr_connect_change(ctx).await
                } else {
                    handler(ctx, user).await
                }
            } else {
                handler(ctx, user).await
            }
        } else {
            handler(ctx, user).await
        }
    } else {
        Response::success(WindTemplate::render(IndexNoCertTemplate {}))
    }
}

pub async fn ugate(certificate: Option<X509>) -> Option<&'static CaracalUser> {
    if let Some(cert) = certificate {
        match fingerprint(&cert) {
            Some(fp) => lookup_user(fp),
            None => None,
        }
    } else {
        None
    }
}

pub async fn ugate_mut(
    certificate: Option<X509>,
) -> Option<&'static mut CaracalUser> {
    if let Some(cert) = certificate {
        match fingerprint(&cert) {
            Some(fp) => lookup_user_mut(fp),
            None => None,
        }
    } else {
        None
    }
}

pub async fn ugate_m(ctx: &RouteContext) -> Option<&'static mut CaracalUser> {
    match &ctx.certificate {
        Some(cert) => match fingerprint(cert) {
            Some(fp) => lookup_user_mut(fp),
            None => None,
        },
        None => None,
    }
}

pub async fn index(
    ctx: RouteContext,
    _user: &'static mut CaracalUser,
) -> Response {
    if let Some(_) = ugate(ctx.certificate).await {
        Response::success(WindTemplate::render(IndexTemplate {}))
    } else {
        Response::success(WindTemplate::render(IndexNoCertTemplate {}))
    }
}

pub async fn feed(
    ctx: RouteContext,
    user: &'static mut CaracalUser,
) -> Response {
    let params = ctx.parameters.clone();
    let default = String::from("500");
    let limit_str = params.get("limit").unwrap_or(&default);

    let limit = match limit_str.parse::<usize>() {
        Ok(result) => result,
        Err(_error) => default.parse::<usize>().unwrap(),
    };

    let Ok(contacts) =
        user.client.get_contact_list(Duration::from_secs(10)).await
    else {
        return Response::temporary_failure("Failed to fetch contact list");
    };

    let filter = Filter::new()
        .kind(Kind::TextNote)
        .kind(Kind::LongFormTextNote)
        .authors(contacts.iter().map(|c| c.public_key))
        .limit(limit);

    let Ok(events) = user.client.database().query(filter).await else {
        return Response::temporary_failure("db error");
    };

    let mut doc = vec![];

    for event in events.into_iter() {
        let usermeta =
            nostru::get_user_metadata(&user.client, event.pubkey).await;

        if let Err(err) = user
            .client
            .subscribe(Filter::new().kind(Kind::Reaction).event(event.id), None)
            .await
        {
            eprintln!("Reactions sub failed: {err}");
        }

        let rfilter = Filter::new().kind(Kind::Reaction).event(event.id);

        let Ok(reactions) = user.client.database().query(rfilter).await else {
            continue;
        };

        match rendering::render_note(
            &event,
            usermeta,
            rendering::NoteRenderMode::Normal,
        ) {
            Ok(mut blocks) => {
                doc.append(&mut blocks);

                let rsummary = nostru::reactions_summary(&reactions);
                if !rsummary.is_empty() {
                    doc.push(Block::Text(Content::new(rsummary).unwrap()));
                }
            }
            Err(_err) => {
                doc.push(Block::Text(
                    Content::new("Cannot process note").unwrap(),
                ));
            }
        }
    }
    let tmpl = FeedTemplate {
        edoc: doc,
        b1: Block::Heading(Level::One, "caracal!".parse().unwrap()),
    };

    Response::success(WindTemplate::render(tmpl))
}

pub async fn person_profile(
    ctx: RouteContext,
    user: &'static mut CaracalUser,
) -> Response {
    let params = ctx.parameters.clone();

    if let Some(npub) = params.get("npub") {
        let Ok(pubkey) = PublicKey::parse(npub) else {
            return resp_invalid_params();
        };

        let usermeta = nostru::get_user_metadata(&user.client, pubkey).await;

        let filter = nostru::notes_filter(500).author(pubkey);

        let notes: Option<Vec<Block>> =
            match user.client.database().query(filter).await {
                Ok(events) => {
                    (rendering::render_notes(
                        events,
                        &user.client,
                        None,
                        None,
                        rendering::NoteRenderMode::Normal,
                    )
                    .await).ok()
                }
                Err(_error) => None,
            };

        let template = PersonIndexTemplate {
            npub: npub.to_string(),
            metadata: usermeta,
            notes_blocks: notes,
        };

        Response::success(WindTemplate::render(template))
    } else {
        resp_invalid_params()
    }
}