From 9cbf9999da9856cc9fe296e596f0ab76aa438ea2 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 7 Jun 2026 09:13:33 -0700 Subject: [PATCH 1/3] fix: return proper HTTP status codes instead of aborting connection When serve() returned Err (e.g. Error::NotFound for a missing note), hyper's serve_connection aborted the connection without writing any HTTP response. A reverse proxy in front then translated the dropped upstream connection into a 502/503, so a routine "note not found" surfaced to clients as a 503 instead of a 404. Catch the error in the service closure and map it to a real response: NotFound -> 404, everything else -> 500. 4xx are logged at debug since unpropagated notes and bad ids are routine; 5xx still log at error. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 15d3604..1bf7a5c 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, @@ -698,8 +698,34 @@ 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 { + match serve(app, req).await { + Ok(resp) => Ok::<_, std::convert::Infallible>(resp), + Err(err) => { + let status = match &err { + Error::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + // 404s are routine (notes that haven't propagated, + // bad ids); only log genuine server errors loudly. + if status.is_server_error() { + error!("serve error ({}): {}", status.as_u16(), err); + } else { + debug!("serve error ({}): {}", status.as_u16(), err); + } + Ok(Response::builder() + .status(status) + .body(Full::new(Bytes::from(format!("{}\n", err)))) + .expect("building error response")) + } + } + } + }), + ) .await { println!("Error serving connection: {:?}", err); From 78c33963703525bc237e003954ee4e1294584255 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 7 Jun 2026 09:20:47 -0700 Subject: [PATCH 2/3] feat: add styled 404 page for missing notes and profiles Replace the bare "Profile not found :(" text and plain-text "Invalid url" response with a shared Damus-styled 404 page (render_not_found_html / not_found_response). The main error handler now serves this page for Error::NotFound, and the nip19-parse-failure and profile-missing paths reuse it too, so every 404 gets consistent branding and a link home. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/html.rs | 90 +++++++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 38 ++++++++++++---------- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/html.rs b/src/html.rs index 5c79f30..e10c70b 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1880,13 +1880,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 +1892,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 +1903,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()); } }; @@ -2357,6 +2352,83 @@ 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") +} + pub fn serve_note_html( app: &Notecrumbs, nip19: &Nip19, diff --git a/src/main.rs b/src/main.rs index 1bf7a5c..a7f3712 100644 --- a/src/main.rs +++ b/src/main.rs @@ -515,9 +515,9 @@ 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")))?); + return Ok(html::not_found_response( + "That doesn't look like a valid Nostr bech32 identifier.", + )); } }; @@ -706,21 +706,25 @@ async fn main() -> Result<(), Box> { match serve(app, req).await { Ok(resp) => Ok::<_, std::convert::Infallible>(resp), Err(err) => { - let status = match &err { - Error::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, + let resp = match &err { + Error::NotFound => { + // 404s are routine (notes that haven't + // propagated yet, bad ids); log quietly. + debug!("serve error (404): {}", err); + html::not_found_response( + "We couldn't find that note or profile \ + on the relays we checked.", + ) + } + _ => { + error!("serve error (500): {}", err); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Full::new(Bytes::from(format!("{}\n", err)))) + .expect("building error response") + } }; - // 404s are routine (notes that haven't propagated, - // bad ids); only log genuine server errors loudly. - if status.is_server_error() { - error!("serve error ({}): {}", status.as_u16(), err); - } else { - debug!("serve error ({}): {}", status.as_u16(), err); - } - Ok(Response::builder() - .status(status) - .body(Full::new(Bytes::from(format!("{}\n", err)))) - .expect("building error response")) + Ok(resp) } } } From eb121de7a863131f8b3d17b50dad5a985e99f7bf Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 7 Jun 2026 10:07:48 -0700 Subject: [PATCH 3/3] feat: add JSON profile pages and JSON-shaped 404s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement serve_profile_json (previously a "todo: profile json" stub). It returns the profile metadata broken out from nostrdb's parsed profile record — pubkey, name, display_name, about, picture, banner, website, nip05, lud06, lud16, lnurl (empty fields omitted) — plus a recent_notes array (the same 12-note newest-first feed the HTML profile page shows). Also serve JSON-shaped 404s on the .json endpoints instead of the HTML 404 page: not_found_json returns {"error":"not_found","message":...}. The main error handler picks JSON vs HTML based on the request path, and the invalid-bech32 path in serve() does the same. Drop the last inline "Profile not found :(" in favor of the shared profile_not_found helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/html.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 27 +++++++++------ 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/html.rs b/src/html.rs index e10c70b..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; @@ -1942,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 */ @@ -2429,6 +2503,18 @@ pub fn not_found_response(message: &str) -> Response> { .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 a7f3712..de521b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(html::not_found_response( - "That doesn't look like a valid Nostr bech32 identifier.", - )); + 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 { @@ -703,6 +706,7 @@ async fn main() -> Result<(), Box> { 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) => { @@ -711,10 +715,13 @@ async fn main() -> Result<(), Box> { // 404s are routine (notes that haven't // propagated yet, bad ids); log quietly. debug!("serve error (404): {}", err); - html::not_found_response( - "We couldn't find that note or profile \ - on the relays we checked.", - ) + 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);