notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

main.rs (11698B)


      1 use std::net::SocketAddr;
      2 
      3 use http_body_util::Full;
      4 use hyper::body::Bytes;
      5 use hyper::header;
      6 use hyper::server::conn::http1;
      7 use hyper::service::service_fn;
      8 use hyper::{Request, Response, StatusCode};
      9 use hyper_util::rt::TokioIo;
     10 use metrics_exporter_prometheus::PrometheusHandle;
     11 use std::sync::Arc;
     12 use tokio::net::TcpListener;
     13 use tracing::{error, info};
     14 
     15 use crate::{
     16     error::Error,
     17     render::{ProfileRenderData, RenderData},
     18 };
     19 use nostr_sdk::prelude::*;
     20 use nostrdb::{Config, Ndb, NoteKey, Transaction};
     21 use std::time::Duration;
     22 
     23 mod abbrev;
     24 mod error;
     25 mod fonts;
     26 mod gradient;
     27 mod html;
     28 mod nip19;
     29 mod pfp;
     30 mod relay_pool;
     31 mod render;
     32 
     33 use relay_pool::RelayPool;
     34 
     35 const FRONTEND_CSS: &str = include_str!("../assets/damus.css");
     36 const POETSEN_FONT: &[u8] = include_bytes!("../fonts/PoetsenOne-Regular.ttf");
     37 const DEFAULT_PFP_IMAGE: &[u8] = include_bytes!("../assets/default_pfp.jpg");
     38 const DAMUS_LOGO_ICON: &[u8] = include_bytes!("../assets/logo_icon.png");
     39 
     40 #[derive(Clone)]
     41 pub struct Notecrumbs {
     42     pub ndb: Ndb,
     43     _keys: Keys,
     44     relay_pool: Arc<RelayPool>,
     45     font_data: egui::FontData,
     46     default_pfp: egui::ImageData,
     47     background: egui::ImageData,
     48     prometheus_handle: PrometheusHandle,
     49 
     50     /// How long do we wait for remote note requests
     51     _timeout: Duration,
     52 }
     53 
     54 #[inline]
     55 pub fn floor_char_boundary(s: &str, index: usize) -> usize {
     56     if index >= s.len() {
     57         s.len()
     58     } else {
     59         let lower_bound = index.saturating_sub(3);
     60         let new_index = s.as_bytes()[lower_bound..=index]
     61             .iter()
     62             .rposition(|b| is_utf8_char_boundary(*b));
     63 
     64         // SAFETY: we know that the character boundary will be within four bytes
     65         unsafe { lower_bound + new_index.unwrap_unchecked() }
     66     }
     67 }
     68 
     69 #[inline]
     70 fn is_utf8_char_boundary(c: u8) -> bool {
     71     // This is bit magic equivalent to: b < 128 || b >= 192
     72     (c as i8) >= -0x40
     73 }
     74 
     75 async fn serve(
     76     app: &Notecrumbs,
     77     r: Request<hyper::body::Incoming>,
     78 ) -> Result<Response<Full<Bytes>>, Error> {
     79     if r.uri().path() == "/metrics" {
     80         let body = app.prometheus_handle.render();
     81         return Ok(Response::builder()
     82             .status(StatusCode::OK)
     83             .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
     84             .body(Full::new(Bytes::from(body)))?);
     85     }
     86 
     87     match r.uri().path() {
     88         "/damus.css" => {
     89             return Ok(Response::builder()
     90                 .status(StatusCode::OK)
     91                 .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
     92                 .body(Full::new(Bytes::from_static(FRONTEND_CSS.as_bytes())))?);
     93         }
     94         "/fonts/PoetsenOne-Regular.ttf" => {
     95             return Ok(Response::builder()
     96                 .status(StatusCode::OK)
     97                 .header(header::CONTENT_TYPE, "font/ttf")
     98                 .header(header::CACHE_CONTROL, "public, max-age=604800, immutable")
     99                 .body(Full::new(Bytes::from_static(POETSEN_FONT)))?);
    100         }
    101         "/assets/default_pfp.jpg" => {
    102             return Ok(Response::builder()
    103                 .status(StatusCode::OK)
    104                 .header(header::CONTENT_TYPE, "image/jpeg")
    105                 .header(header::CACHE_CONTROL, "public, max-age=604800")
    106                 .body(Full::new(Bytes::from_static(DEFAULT_PFP_IMAGE)))?);
    107         }
    108         "/assets/logo_icon.png" => {
    109             return Ok(Response::builder()
    110                 .status(StatusCode::OK)
    111                 .header(header::CONTENT_TYPE, "image/png")
    112                 .header(header::CACHE_CONTROL, "public, max-age=604800, immutable")
    113                 .body(Full::new(Bytes::from_static(DAMUS_LOGO_ICON)))?);
    114         }
    115         "/" => {
    116             return html::serve_homepage(r);
    117         }
    118         _ => {}
    119     }
    120 
    121     let is_png = r.uri().path().ends_with(".png");
    122     let is_json = r.uri().path().ends_with(".json");
    123     let until = if is_png {
    124         4
    125     } else if is_json {
    126         5
    127     } else {
    128         0
    129     };
    130 
    131     let path_len = r.uri().path().len();
    132     let nip19 = match Nip19::from_bech32(&r.uri().path()[1..path_len - until]) {
    133         Ok(nip19) => nip19,
    134         Err(_) => {
    135             return Ok(Response::builder()
    136                 .status(StatusCode::NOT_FOUND)
    137                 .body(Full::new(Bytes::from("Invalid url\n")))?);
    138         }
    139     };
    140 
    141     // render_data is always returned, it just might be empty
    142     let mut render_data = {
    143         let txn = Transaction::new(&app.ndb)?;
    144         match render::get_render_data(&app.ndb, &txn, &nip19) {
    145             Err(_err) => {
    146                 return Ok(Response::builder()
    147                     .status(StatusCode::BAD_REQUEST)
    148                     .body(Full::new(Bytes::from(
    149                         "nsecs are not supported, what were you thinking!?\n",
    150                     )))?);
    151             }
    152             Ok(render_data) => render_data,
    153         }
    154     };
    155 
    156     // fetch extra data if we are missing it
    157     if !render_data.is_complete() {
    158         if let Err(err) = render_data
    159             .complete(app.ndb.clone(), app.relay_pool.clone(), nip19.clone())
    160             .await
    161         {
    162             error!("Error fetching completion data: {err}");
    163         }
    164     }
    165 
    166     if let RenderData::Profile(profile_opt) = &render_data {
    167         let maybe_pubkey = {
    168             let txn = Transaction::new(&app.ndb)?;
    169             match profile_opt {
    170                 Some(ProfileRenderData::Profile(profile_key)) => {
    171                     if let Ok(profile_rec) = app.ndb.get_profile_by_key(&txn, *profile_key) {
    172                         let note_key = NoteKey::new(profile_rec.record().note_key());
    173                         if let Ok(profile_note) = app.ndb.get_note_by_key(&txn, note_key) {
    174                             Some(*profile_note.pubkey())
    175                         } else {
    176                             None
    177                         }
    178                     } else {
    179                         None
    180                     }
    181                 }
    182                 Some(ProfileRenderData::Missing(pk)) => Some(*pk),
    183                 None => None,
    184             }
    185         };
    186 
    187         if let Some(pubkey) = maybe_pubkey {
    188             if let Err(err) =
    189                 render::fetch_profile_feed(app.relay_pool.clone(), app.ndb.clone(), pubkey).await
    190             {
    191                 error!("Error fetching profile feed: {err}");
    192             }
    193         }
    194     }
    195 
    196     if is_png {
    197         let data = render::render_note(app, &render_data);
    198 
    199         Ok(Response::builder()
    200             .header(header::CONTENT_TYPE, "image/png")
    201             .status(StatusCode::OK)
    202             .body(Full::new(Bytes::from(data)))?)
    203     } else if is_json {
    204         match render_data {
    205             RenderData::Note(note_rd) => html::serve_note_json(&app.ndb, &note_rd),
    206             RenderData::Profile(_profile_rd) => {
    207                 return Ok(Response::builder()
    208                     .status(StatusCode::NOT_FOUND)
    209                     .body(Full::new(Bytes::from("todo: profile json")))?);
    210             }
    211         }
    212     } else {
    213         match render_data {
    214             RenderData::Note(note_rd) => html::serve_note_html(app, &nip19, &note_rd, r),
    215             RenderData::Profile(profile_rd) => {
    216                 html::serve_profile_html(app, &nip19, profile_rd.as_ref(), r)
    217             }
    218         }
    219     }
    220 }
    221 
    222 fn get_env_timeout() -> Duration {
    223     let timeout_env = std::env::var("TIMEOUT_MS").unwrap_or("2000".to_string());
    224     let timeout_ms: u64 = timeout_env.parse().unwrap_or(2000);
    225     Duration::from_millis(timeout_ms)
    226 }
    227 
    228 fn get_gradient() -> egui::ColorImage {
    229     use egui::{Color32, ColorImage};
    230     //use egui::pos2;
    231     use gradient::Gradient;
    232 
    233     //let gradient = Gradient::linear(Color32::LIGHT_GRAY, Color32::DARK_GRAY);
    234     //let size = pfp::PFP_SIZE as usize;
    235     //let radius = (pfp::PFP_SIZE as f32) / 2.0;
    236     //let center = pos2(radius, radius);
    237 
    238     let scol = [0x1C, 0x55, 0xFF];
    239     //let ecol = [0xFA, 0x0D, 0xD4];
    240     let mcol = [0x7F, 0x35, 0xAB];
    241     //let ecol = [0xFF, 0x0B, 0xD6];
    242     let ecol = [0xC0, 0x2A, 0xBE];
    243 
    244     // TODO: skia has r/b colors swapped for some reason, fix this
    245     let start_color = Color32::from_rgb(scol[2], scol[1], scol[0]);
    246     let mid_color = Color32::from_rgb(mcol[2], mcol[1], mcol[0]);
    247     let end_color = Color32::from_rgb(ecol[2], ecol[1], ecol[0]);
    248 
    249     let gradient = Gradient::linear_many(vec![start_color, mid_color, end_color]);
    250     let pixels = gradient.to_pixel_row();
    251     let width = pixels.len();
    252     let height = 1;
    253 
    254     ColorImage {
    255         size: [width, height],
    256         pixels,
    257     }
    258 }
    259 
    260 fn get_default_pfp() -> egui::ColorImage {
    261     let mut dyn_image =
    262         ::image::load_from_memory(DEFAULT_PFP_IMAGE).expect("failed to load embedded default pfp");
    263     pfp::process_pfp_bitmap(&mut dyn_image)
    264 }
    265 
    266 #[tokio::main]
    267 async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    268     use tracing_subscriber;
    269 
    270     tracing_subscriber::fmt::init();
    271 
    272     let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    273 
    274     // We create a TcpListener and bind it to 127.0.0.1:3000
    275     let listener = TcpListener::bind(addr).await?;
    276     info!("Listening on 0.0.0.0:3000");
    277 
    278     let cfg = Config::new();
    279     let ndb = Ndb::new(".", &cfg).expect("ndb failed to open");
    280     let keys = Keys::generate();
    281     let timeout = get_env_timeout();
    282     let prometheus_handle = metrics_exporter_prometheus::PrometheusBuilder::new()
    283         .install_recorder()
    284         .expect("install prometheus recorder");
    285     let relay_pool = Arc::new(
    286         RelayPool::new(
    287             keys.clone(),
    288             &["wss://relay.damus.io", "wss://nostr.wine", "wss://nos.lol"],
    289             timeout,
    290         )
    291         .await?,
    292     );
    293     spawn_relay_pool_metrics_logger(relay_pool.clone());
    294     let default_pfp = egui::ImageData::Color(Arc::new(get_default_pfp()));
    295     let background = egui::ImageData::Color(Arc::new(get_gradient()));
    296     let font_data = egui::FontData::from_static(include_bytes!("../fonts/NotoSans-Regular.ttf"));
    297 
    298     let app = Notecrumbs {
    299         ndb,
    300         _keys: keys,
    301         relay_pool,
    302         _timeout: timeout,
    303         background,
    304         font_data,
    305         default_pfp,
    306         prometheus_handle,
    307     };
    308 
    309     // We start a loop to continuously accept incoming connections
    310     loop {
    311         let (stream, _) = listener.accept().await?;
    312 
    313         // Use an adapter to access something implementing `tokio::io` traits as if they implement
    314         // `hyper::rt` IO traits.
    315         let io = TokioIo::new(stream);
    316 
    317         let app_copy = app.clone();
    318 
    319         // Spawn a tokio task to serve multiple connections concurrently
    320         tokio::task::spawn(async move {
    321             // Finally, we bind the incoming connection to our `hello` service
    322             if let Err(err) = http1::Builder::new()
    323                 // `service_fn` converts our function in a `Service`
    324                 .serve_connection(io, service_fn(|req| serve(&app_copy, req)))
    325                 .await
    326             {
    327                 println!("Error serving connection: {:?}", err);
    328             }
    329         });
    330     }
    331 }
    332 
    333 fn spawn_relay_pool_metrics_logger(pool: Arc<RelayPool>) {
    334     tokio::spawn(async move {
    335         let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
    336         loop {
    337             ticker.tick().await;
    338             let (stats, tracked) = pool.relay_stats().await;
    339             metrics::gauge!("relay_pool_known_relays", tracked as f64);
    340             info!(
    341                 total_relays = tracked,
    342                 ensure_calls = stats.ensure_calls,
    343                 relays_added = stats.relays_added,
    344                 connect_successes = stats.connect_successes,
    345                 connect_failures = stats.connect_failures,
    346                 "relay pool metrics snapshot"
    347             );
    348         }
    349     });
    350 }