notecrumbs

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

main.rs (8730B)


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