notecrumbs

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

main.rs (9260B)


      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 log::{debug, info};
     11 use std::io::Write;
     12 use std::sync::Arc;
     13 use tokio::net::TcpListener;
     14 
     15 use crate::error::Error;
     16 use crate::render::RenderData;
     17 use nostr_sdk::prelude::*;
     18 use nostrdb::{Config, Ndb};
     19 use std::time::Duration;
     20 
     21 use lru::LruCache;
     22 
     23 mod abbrev;
     24 mod error;
     25 mod fonts;
     26 mod gradient;
     27 mod html;
     28 mod nip19;
     29 mod pfp;
     30 mod render;
     31 
     32 type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
     33 
     34 #[derive(Clone)]
     35 pub struct Notecrumbs {
     36     ndb: Ndb,
     37     keys: Keys,
     38     font_data: egui::FontData,
     39     img_cache: Arc<ImageCache>,
     40     default_pfp: egui::ImageData,
     41     background: egui::ImageData,
     42 
     43     /// How long do we wait for remote note requests
     44     timeout: Duration,
     45 }
     46 
     47 pub struct FindNoteResult {
     48     note: Option<Event>,
     49     profile: Option<Event>,
     50 }
     51 
     52 pub async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<FindNoteResult, Error> {
     53     let opts = Options::new().shutdown_on_drop(true);
     54     let client = Client::with_opts(&app.keys, opts);
     55 
     56     let mut num_relays: i32 = 2;
     57     let _ = client.add_relay("wss://relay.damus.io");
     58     let _ = client.add_relay("wss://relay.nostr.band").await;
     59 
     60     let other_relays = nip19::to_relays(nip19);
     61     for relay in other_relays {
     62         let _ = client.add_relay(relay).await;
     63         num_relays += 1;
     64     }
     65 
     66     client.connect().await;
     67 
     68     let filters = nip19::to_filters(nip19)?;
     69 
     70     client
     71         .req_events_of(filters.clone(), Some(app.timeout))
     72         .await;
     73 
     74     let mut note: Option<Event> = None;
     75     let mut profile: Option<Event> = None;
     76     let mut ends: i32 = 0;
     77 
     78     loop {
     79         match client.notifications().recv().await? {
     80             RelayPoolNotification::Event { event, .. } => {
     81                 debug!("got event 1 {:?}", event);
     82                 note = Some(event);
     83                 return Ok(FindNoteResult { note, profile });
     84             }
     85             RelayPoolNotification::RelayStatus { .. } => continue,
     86             RelayPoolNotification::Message { message, .. } => match message {
     87                 RelayMessage::Event { event, .. } => {
     88                     if event.kind == Kind::Metadata {
     89                         debug!("got profile {:?}", event);
     90                         profile = Some(*event);
     91                     } else {
     92                         debug!("got event {:?}", event);
     93                         note = Some(*event);
     94                     }
     95                 }
     96                 RelayMessage::EndOfStoredEvents(_) => {
     97                     ends += 1;
     98                     let has_any = note.is_some() || profile.is_some();
     99                     if has_any || ends >= num_relays {
    100                         return Ok(FindNoteResult { note, profile });
    101                     }
    102                 }
    103                 _ => continue,
    104             },
    105             RelayPoolNotification::Stop | RelayPoolNotification::Shutdown => {
    106                 return Err(Error::NotFound);
    107             }
    108         }
    109     }
    110 }
    111 
    112 #[inline]
    113 pub fn floor_char_boundary(s: &str, index: usize) -> usize {
    114     if index >= s.len() {
    115         s.len()
    116     } else {
    117         let lower_bound = index.saturating_sub(3);
    118         let new_index = s.as_bytes()[lower_bound..=index]
    119             .iter()
    120             .rposition(|b| is_utf8_char_boundary(*b));
    121 
    122         // SAFETY: we know that the character boundary will be within four bytes
    123         unsafe { lower_bound + new_index.unwrap_unchecked() }
    124     }
    125 }
    126 
    127 #[inline]
    128 fn is_utf8_char_boundary(c: u8) -> bool {
    129     // This is bit magic equivalent to: b < 128 || b >= 192
    130     (c as i8) >= -0x40
    131 }
    132 
    133 fn serve_profile_html(
    134     app: &Notecrumbs,
    135     nip: &Nip19,
    136     profile: &render::ProfileRenderData,
    137     r: Request<hyper::body::Incoming>,
    138 ) -> Result<Response<Full<Bytes>>, Error> {
    139     let mut data = Vec::new();
    140     write!(data, "TODO: profile pages\n");
    141 
    142     Ok(Response::builder()
    143         .header(header::CONTENT_TYPE, "text/html")
    144         .status(StatusCode::OK)
    145         .body(Full::new(Bytes::from(data)))?)
    146 }
    147 
    148 async fn serve(
    149     app: &Notecrumbs,
    150     r: Request<hyper::body::Incoming>,
    151 ) -> Result<Response<Full<Bytes>>, Error> {
    152     let is_png = r.uri().path().ends_with(".png");
    153     let until = if is_png { 4 } else { 0 };
    154 
    155     let path_len = r.uri().path().len();
    156     let nip19 = match Nip19::from_bech32(&r.uri().path()[1..path_len - until]) {
    157         Ok(nip19) => nip19,
    158         Err(_) => {
    159             return Ok(Response::builder()
    160                 .status(StatusCode::NOT_FOUND)
    161                 .body(Full::new(Bytes::from("Invalid url\n")))?);
    162         }
    163     };
    164 
    165     // render_data is always returned, it just might be empty
    166     let partial_render_data = match render::get_render_data(&app, &nip19) {
    167         Err(_err) => {
    168             return Ok(Response::builder()
    169                 .status(StatusCode::BAD_REQUEST)
    170                 .body(Full::new(Bytes::from(
    171                     "nsecs are not supported, what were you thinking!?\n",
    172                 )))?);
    173         }
    174         Ok(render_data) => render_data,
    175     };
    176 
    177     // fetch extra data if we are missing it
    178     let render_data = partial_render_data.complete(&app, &nip19).await;
    179 
    180     if is_png {
    181         let data = render::render_note(&app, &render_data);
    182 
    183         Ok(Response::builder()
    184             .header(header::CONTENT_TYPE, "image/png")
    185             .status(StatusCode::OK)
    186             .body(Full::new(Bytes::from(data)))?)
    187     } else {
    188         match render_data {
    189             RenderData::Note(note_rd) => html::serve_note_html(app, &nip19, &note_rd, r),
    190             RenderData::Profile(profile_rd) => serve_profile_html(app, &nip19, &profile_rd, r),
    191         }
    192     }
    193 }
    194 
    195 fn get_env_timeout() -> Duration {
    196     let timeout_env = std::env::var("TIMEOUT_MS").unwrap_or("2000".to_string());
    197     let timeout_ms: u64 = timeout_env.parse().unwrap_or(2000);
    198     Duration::from_millis(timeout_ms)
    199 }
    200 
    201 fn get_gradient() -> egui::ColorImage {
    202     use egui::{Color32, ColorImage};
    203     //use egui::pos2;
    204     use gradient::Gradient;
    205 
    206     //let gradient = Gradient::linear(Color32::LIGHT_GRAY, Color32::DARK_GRAY);
    207     //let size = pfp::PFP_SIZE as usize;
    208     //let radius = (pfp::PFP_SIZE as f32) / 2.0;
    209     //let center = pos2(radius, radius);
    210 
    211     let scol = [0x1C, 0x55, 0xFF];
    212     //let ecol = [0xFA, 0x0D, 0xD4];
    213     let mcol = [0x7F, 0x35, 0xAB];
    214     //let ecol = [0xFF, 0x0B, 0xD6];
    215     let ecol = [0xC0, 0x2A, 0xBE];
    216 
    217     // TODO: skia has r/b colors swapped for some reason, fix this
    218     let start_color = Color32::from_rgb(scol[2], scol[1], scol[0]);
    219     let mid_color = Color32::from_rgb(mcol[2], mcol[1], mcol[0]);
    220     let end_color = Color32::from_rgb(ecol[2], ecol[1], ecol[0]);
    221 
    222     let gradient = Gradient::linear_many(vec![start_color, mid_color, end_color]);
    223     let pixels = gradient.to_pixel_row();
    224     let width = pixels.len();
    225     let height = 1;
    226 
    227     ColorImage {
    228         size: [width, height],
    229         pixels,
    230     }
    231 }
    232 
    233 fn get_default_pfp() -> egui::ColorImage {
    234     let img = std::fs::read("assets/default_pfp.jpg").expect("default pfp missing");
    235     let mut dyn_image = image::load_from_memory(&img).expect("failed to load default pfp");
    236     pfp::process_pfp_bitmap(&mut dyn_image)
    237 }
    238 
    239 #[tokio::main]
    240 async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    241     env_logger::init();
    242 
    243     let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    244 
    245     // We create a TcpListener and bind it to 127.0.0.1:3000
    246     let listener = TcpListener::bind(addr).await?;
    247     info!("Listening on 0.0.0.0:3000");
    248 
    249     // Since ndk-sdk will verify for us, we don't need to do it on the db side
    250     let mut cfg = Config::new();
    251     cfg.skip_validation(true);
    252     let ndb = Ndb::new(".", &cfg).expect("ndb failed to open");
    253     let keys = Keys::generate();
    254     let timeout = get_env_timeout();
    255     let img_cache = Arc::new(LruCache::new(std::num::NonZeroUsize::new(64).unwrap()));
    256     let default_pfp = egui::ImageData::Color(Arc::new(get_default_pfp()));
    257     let background = egui::ImageData::Color(Arc::new(get_gradient()));
    258     let font_data = egui::FontData::from_static(include_bytes!("../fonts/NotoSans-Regular.ttf"));
    259 
    260     let app = Notecrumbs {
    261         ndb,
    262         keys,
    263         timeout,
    264         img_cache,
    265         background,
    266         font_data,
    267         default_pfp,
    268     };
    269 
    270     // We start a loop to continuously accept incoming connections
    271     loop {
    272         let (stream, _) = listener.accept().await?;
    273 
    274         // Use an adapter to access something implementing `tokio::io` traits as if they implement
    275         // `hyper::rt` IO traits.
    276         let io = TokioIo::new(stream);
    277 
    278         let app_copy = app.clone();
    279 
    280         // Spawn a tokio task to serve multiple connections concurrently
    281         tokio::task::spawn(async move {
    282             // Finally, we bind the incoming connection to our `hello` service
    283             if let Err(err) = http1::Builder::new()
    284                 // `service_fn` converts our function in a `Service`
    285                 .serve_connection(io, service_fn(|req| serve(&app_copy, req)))
    286                 .await
    287             {
    288                 println!("Error serving connection: {:?}", err);
    289             }
    290         });
    291     }
    292 }