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, ¬e_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, ¬e_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 }