diff --git a/src/html.rs b/src/html.rs index 5c79f30..626bc20 100644 --- a/src/html.rs +++ b/src/html.rs @@ -352,6 +352,85 @@ pub fn serve_note_json( .body(Full::new(Bytes::from(body)))?) } +pub fn serve_profile_json( + ndb: &Ndb, + profile_rd: Option<&ProfileRenderData>, +) -> Result>, Error> { + use serde_json::{Map, Value}; + + let profile_key = match profile_rd { + Some(ProfileRenderData::Profile(profile_key)) => *profile_key, + None | Some(ProfileRenderData::Missing(_)) => return Err(Error::NotFound), + }; + + let txn = Transaction::new(ndb)?; + + let profile_rec = match ndb.get_profile_by_key(&txn, profile_key) { + Ok(profile_rec) => profile_rec, + Err(_) => return Err(Error::NotFound), + }; + let record = profile_rec.record(); + + let profile_note_key = NoteKey::new(record.note_key()); + let profile_note = match ndb.get_note_by_key(&txn, profile_note_key) { + Ok(note) => note, + Err(_) => return Err(Error::NotFound), + }; + + // Break out the parsed metadata fields from nostrdb rather than echoing the + // raw kind-0 event with its stringified `content`. + let mut obj = Map::new(); + obj.insert( + "pubkey".to_string(), + Value::String(hex::encode(profile_note.pubkey())), + ); + + if let Some(profile) = record.profile() { + let mut put = |key: &str, val: Option<&str>| { + if let Some(v) = val.map(str::trim).filter(|s| !s.is_empty()) { + obj.insert(key.to_string(), Value::String(v.to_string())); + } + }; + put("name", profile.name()); + put("display_name", profile.display_name()); + put("about", profile.about()); + put("picture", profile.picture()); + put("banner", profile.banner()); + put("website", profile.website()); + put("nip05", profile.nip05()); + put("lud06", profile.lud06()); + put("lud16", profile.lud16()); + } + if let Some(lnurl) = record.lnurl().map(str::trim).filter(|s| !s.is_empty()) { + obj.insert("lnurl".to_string(), Value::String(lnurl.to_string())); + } + + // Recent notes feed, newest first — mirrors the HTML profile page. + let mut recent_notes = Vec::new(); + let notes_filter = Filter::new() + .authors([profile_note.pubkey()]) + .kinds([1]) + .limit(PROFILE_FEED_RECENT_LIMIT as u64) + .build(); + if let Ok(mut results) = ndb.query(&txn, &[notes_filter], PROFILE_FEED_RECENT_LIMIT as i32) { + results.sort_by_key(|result| result.note.created_at()); + results.reverse(); + for result in &results { + if let Ok(note_value) = serde_json::from_str::(&result.note.json()?) { + recent_notes.push(note_value); + } + } + } + obj.insert("recent_notes".to_string(), Value::Array(recent_notes)); + + let body = serde_json::to_vec(&Value::Object(obj))?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .status(StatusCode::OK) + .body(Full::new(Bytes::from(body)))?) +} + fn ends_with(haystack: &str, needle: &str) -> bool { if haystack.len() < needle.len() { return false; @@ -1880,13 +1959,8 @@ fn pfp_url_attr(profile: Option>, base_url: &str) -> String { html_escape::encode_double_quoted_attribute(&pfp_url_raw).into_owned() } -fn profile_not_found() -> Result>, http::Error> { - let mut data = Vec::new(); - let _ = write!(data, "Profile not found :("); - Response::builder() - .header(header::CONTENT_TYPE, "text/html") - .status(StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from(data))) +fn profile_not_found() -> Response> { + not_found_response("We couldn't find that profile on the relays we checked.") } pub fn serve_profile_html( @@ -1897,7 +1971,7 @@ pub fn serve_profile_html( ) -> Result>, Error> { let profile_key = match profile_rd { None | Some(ProfileRenderData::Missing(_)) => { - return Ok(profile_not_found()?); + return Ok(profile_not_found()); } Some(ProfileRenderData::Profile(profile_key)) => *profile_key, @@ -1908,7 +1982,7 @@ pub fn serve_profile_html( let profile_rec = match app.ndb.get_profile_by_key(&txn, profile_key) { Ok(profile_rec) => profile_rec, Err(_) => { - return Ok(profile_not_found()?); + return Ok(profile_not_found()); } }; @@ -1947,12 +2021,7 @@ pub fn serve_profile_html( let profile_note_key = NoteKey::new(profile_record.note_key()); let Ok(profile_note) = app.ndb.get_note_by_key(&txn, profile_note_key) else { - let mut data = Vec::new(); - let _ = write!(data, "Profile not found :("); - return Ok(Response::builder() - .header(header::CONTENT_TYPE, "text/html") - .status(StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from(data)))?); + return Ok(profile_not_found()); }; /* relays */ @@ -2357,6 +2426,95 @@ fn get_base_url() -> String { std::env::var("NOTECRUMBS_BASE_URL").unwrap_or_else(|_| "https://damus.io".to_string()) } +/// Render the shared, Damus-styled 404 page. `message` is the human-friendly +/// explanation shown in the card (already plain text — it will be escaped). +fn render_not_found_html(message: &str) -> Vec { + let base_url = get_base_url(); + let page_title = "Not found — notecrumbs"; + let og_image_url = format!("{}/assets/default_pfp.jpg", base_url); + + let canonical_url_attr = html_escape::encode_double_quoted_attribute(&base_url).into_owned(); + let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url).into_owned(); + let page_title_attr = html_escape::encode_double_quoted_attribute(page_title).into_owned(); + let page_title_html = html_escape::encode_text(page_title).into_owned(); + let message_html = html_escape::encode_text(message).into_owned(); + + let mut data = Vec::new(); + let _ = write!( + data, + r##" + + + + {page_title} + + + + + + + + + + + + + +
+
+ Damus +
+ damus.io +
+
+
+
+

404

+

{message}

+

+ Double-check the bech32 identifier in the URL, or head back to the homepage. +

+
+
+ +
+ + +"##, + page_title = page_title_html, + page_title_attr = page_title_attr, + canonical_url = canonical_url_attr, + og_image = og_image_attr, + message = message_html, + ); + + data +} + +/// Build a styled 404 response with the given explanatory message. +pub fn not_found_response(message: &str) -> Response> { + let body = render_not_found_html(message); + Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from(body))) + .expect("building 404 response") +} + +/// Build a JSON 404 response for the `.json` endpoints. +pub fn not_found_json(message: &str) -> Response> { + let message_json = + serde_json::to_string(message).unwrap_or_else(|_| "\"not found\"".to_string()); + let body = format!("{{\"error\":\"not_found\",\"message\":{message_json}}}\n"); + Response::builder() + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from(body))) + .expect("building json 404 response") +} + pub fn serve_note_html( app: &Notecrumbs, nip19: &Nip19, diff --git a/src/main.rs b/src/main.rs index 15d3604..de521b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use hyper_util::rt::TokioIo; use metrics_exporter_prometheus::PrometheusHandle; use std::sync::Arc; use tokio::net::TcpListener; -use tracing::{error, info}; +use tracing::{debug, error, info}; use crate::{ error::Error, @@ -515,9 +515,12 @@ async fn serve( let nip19 = match Nip19::from_bech32(&r.uri().path()[1..path_len - until]) { Ok(nip19) => nip19, Err(_) => { - return Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Invalid url\n")))?); + let msg = "That doesn't look like a valid Nostr bech32 identifier."; + return Ok(if is_json { + html::not_found_json(msg) + } else { + html::not_found_response(msg) + }); } }; @@ -581,9 +584,9 @@ async fn serve( } else if is_json { match render_data { RenderData::Note(note_rd) => html::serve_note_json(&app.ndb, ¬e_rd), - RenderData::Profile(_profile_rd) => Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("todo: profile json")))?), + RenderData::Profile(profile_rd) => { + html::serve_profile_json(&app.ndb, profile_rd.as_ref()) + } } } else { match render_data { @@ -698,8 +701,42 @@ async fn main() -> Result<(), Box> { tokio::task::spawn(async move { // Finally, we bind the incoming connection to our `hello` service if let Err(err) = http1::Builder::new() - // `service_fn` converts our function in a `Service` - .serve_connection(io, service_fn(|req| serve(&app_copy, req))) + .serve_connection( + io, + service_fn(|req| { + let app = &app_copy; + async move { + let wants_json = req.uri().path().ends_with(".json"); + match serve(app, req).await { + Ok(resp) => Ok::<_, std::convert::Infallible>(resp), + Err(err) => { + let resp = match &err { + Error::NotFound => { + // 404s are routine (notes that haven't + // propagated yet, bad ids); log quietly. + debug!("serve error (404): {}", err); + let msg = "We couldn't find that note or profile \ + on the relays we checked."; + if wants_json { + html::not_found_json(msg) + } else { + html::not_found_response(msg) + } + } + _ => { + error!("serve error (500): {}", err); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Full::new(Bytes::from(format!("{}\n", err)))) + .expect("building error response") + } + }; + Ok(resp) + } + } + } + }), + ) .await { println!("Error serving connection: {:?}", err);