caracal 0.4.2

Nostr client for Gemini
mod bookmarks;
mod community;
mod contacts;
mod dm;
mod filters;
mod following;
mod hashtags;
mod inbox;
mod manual;
mod mute;
mod nostr_db;
mod notes;
mod nsite;
mod outbox;
mod polls;
mod profile;
mod remote_signer;
mod response;
mod search;
mod sec;
mod settings;
mod www;

mod route_prelude;

pub use response::*;

pub use bookmarks::{
    bookmark_public_add, public_bookmarks, public_bookmarks_delete_event,
};
pub use community::{
    nostriches_list_all, nostriches_list_recent, nostriches_search,
};
pub use contacts::contact_list_index;
pub use dm::{dm_all, 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 manual::manual_root;
pub use mute::{person_mute, person_unmute};
pub use nostr_db::db_purge;
pub use notes::{
    draft_body_append, draft_edit, draft_longform_body_append,
    draft_longform_delete_last_line, draft_longform_edit, draft_longform_new,
    draft_longform_new_with_id, draft_longform_post, draft_longform_preview,
    draft_longform_set, draft_new, draft_post, draft_upload_file, note_delete,
    note_react_custom, note_react_select, note_reply, note_repost, note_thread,
    post_note,
};
pub use nsite::nsites_index;
pub use outbox::outbox;
pub use polls::{poll_details, poll_respond, polls_list};
pub use profile::{
    profile_edit, profile_index, profile_init_reset, profile_titan_edit,
    profile_upload,
};
pub use remote_signer::{
    nostr_connect_bunker, nostr_connect_client_initiated, nostr_connect_index,
};
pub use search::search_content;
pub use settings::{
    relay_modify, relays_add_from_popular, relays_settings, settings_index,
    ui_settings, ui_settings_chlocale,
};
pub use www::route_www_get;

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

use urlencoding::{decode, encode};

use askama::Template;
use colored::*;

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

use chrono::DateTime;
use nostr_sdk::prelude::*;

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)]
#[template(path = "index.gmi", escape = "txt")]
struct IndexTemplate<'a> {
    connect_uri: Option<&'a NostrConnectURI>,
    connect_relays: Option<Vec<String>>,
    profile_metadata: Option<Metadata>,
}

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

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

#[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>>,
    payment_targets: Option<Event>,
}

pub 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")
    }
}

/// Main route handler
/// If the user has a certificate, call the route
/// Otherwise, redirect to the "no cert" page to tell the user to create a cert
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(
                "Could not load your certificate",
            );
        };

        // Print the request as gemtext
        println!("{} {}", "From:".red(), fingerprint.yellow());
        println!("{} {}", "=>".yellow(), ctx.url.path().blue());

        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()
                    && !ctx.url.path().starts_with("/connect")
                {
                    // No connect URI: render the nostr connect homepage (no redirect)
                    nostr_connect_index(ctx, user).await
                } else {
                    handler(ctx, user).await
                }
            } else {
                handler(ctx, user).await
            }
        } else {
            let resp = handler(ctx, user).await;

            match resp.status {
                10 => println!("{}", resp.status.to_string().blue()),
                20..=22 => println!("{}", resp.status.to_string().green()),
                _ => println!("{}", resp.status.to_string().red()),
            }

            resp
        }
    } 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 about(_ctx: RouteContext) -> Response {
    Response::success(WindTemplate::render(AboutTemplate {}))
}

pub async fn index(
    _ctx: RouteContext,
    user: &'static mut CaracalUser,
) -> Response {
    let Ok(signer) = user.client.signer().await else {
        return Response::temporary_failure("Signer error!");
    };

    match signer.get_public_key().await {
        Ok(pubkey) => {
            let profile_metadata =
                nostru::get_user_metadata(&user.client, pubkey).await;

            let connect_relays = user.connect_uri.as_ref().map(|uri| {
                uri.relays().iter().map(|r| r.to_string()).collect()
            });

            Response::success(WindTemplate::render(IndexTemplate {
                profile_metadata,
                connect_uri: user.connect_uri.as_ref(),
                connect_relays,
            }))
        }
        Err(_) => {
            // TODO: handle error
            Response::temporary_redirect("/connect")
        }
    }
}

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(mut contacts) = user.contact_list_pubkeys().await else {
        return Response::temporary_failure("Failed to fetch contact list");
    };

    let mute_list = user.mute_list().await;

    // Remove muted public keys
    contacts.retain(|pk| !mute_list.public_keys.contains(pk));

    let filter = Filter::new()
        .kind(Kind::TextNote)
        .kind(Kind::LongFormTextNote)
        .authors(contacts)
        .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;

        match rendering::render_note(
            &user.client,
            &event,
            usermeta,
            rendering::NoteRenderOptions::default()
                | rendering::NoteRenderOptions::SHOW_HASHTAGS,
        )
        .await
        {
            Ok(mut blocks) => {
                doc.append(&mut blocks);
            }
            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();

    let Some(npub) = params.get("npub") else {
        return resp_invalid_params();
    };

    let Ok(pubkey) = PublicKey::parse(npub) else {
        return resp_invalid_params();
    };

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

    let _ = user
        .client
        .subscribe(nostru::notes_filter().author(pubkey).limit(500), None)
        .await;

    // Payment targets
    let Ok(pt_events) = user
        .fetch_quick(
            Filter::new()
                .kind(Kind::Custom(10133))
                .author(pubkey)
                .limit(1),
        )
        .await
    else {
        return Response::temporary_failure("Could not fetch payment targets");
    };

    let payment_targets = pt_events.first_owned();

    let notes: Option<Vec<Block>> = match user
        .client
        .database()
        .query(nostru::notes_filter().author(pubkey).limit(300))
        .await
    {
        Ok(events) => (rendering::render_notes(
            events,
            &user.client,
            None,
            None,
            rendering::NoteRenderOptions::default(),
        )
        .await)
            .ok(),
        Err(_error) => None,
    };

    let template = PersonIndexTemplate {
        npub: pubkey.to_bech32().unwrap(),
        metadata: usermeta,
        notes_blocks: notes,
        payment_targets,
    };

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