Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 173 additions & 15 deletions src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response<Full<Bytes>>, 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::<Value>(&result.note.json()?) {
recent_notes.push(note_value);
}
}
}
Comment on lines +415 to +423

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate recent-note query failures instead of returning an empty feed.

Lines 415-423 swallow ndb.query(...) errors and still return 200 with recent_notes: []. That makes a backend failure indistinguishable from a genuinely empty profile, so API clients can cache incomplete data as truth.

Suggested fix
-    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::<Value>(&result.note.json()?) {
-                recent_notes.push(note_value);
-            }
-        }
-    }
+    let 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::<Value>(&result.note.json()?) {
+            recent_notes.push(note_value);
+        }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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::<Value>(&result.note.json()?) {
recent_notes.push(note_value);
}
}
}
let 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::<Value>(&result.note.json()?) {
recent_notes.push(note_value);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/html.rs` around lines 415 - 423, The current code swallows errors from
ndb.query(...) causing empty feeds instead of surfacing backend failures; change
the block that starts with ndb.query(...) to propagate query errors (use the ?
operator or return Err(...) from the enclosing function when ndb.query fails)
rather than using if let Ok(...). Keep the existing sorting and JSON parsing
loop (serde_json::from_str, result.note.json()) but ensure the call to
ndb.query(&txn, &[notes_filter], PROFILE_FEED_RECENT_LIMIT as i32) is assigned
with a fallible binding (e.g., let results = ndb.query(...) ?;) so query
failures are returned to the caller.

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;
Expand Down Expand Up @@ -1880,13 +1959,8 @@ fn pfp_url_attr(profile: Option<NdbProfile<'_>>, base_url: &str) -> String {
html_escape::encode_double_quoted_attribute(&pfp_url_raw).into_owned()
}

fn profile_not_found() -> Result<http::Response<http_body_util::Full<bytes::Bytes>>, 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<Full<Bytes>> {
not_found_response("We couldn't find that profile on the relays we checked.")
}

pub fn serve_profile_html(
Expand All @@ -1897,7 +1971,7 @@ pub fn serve_profile_html(
) -> Result<Response<Full<Bytes>>, 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,
Expand All @@ -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());
}
};

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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<u8> {
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##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{page_title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="{page_title_attr}" />
<meta name="robots" content="noindex" />
<link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
<link rel="stylesheet" href="/damus.css?v=7" type="text/css" />
<meta property="og:title" content="{page_title_attr}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{canonical_url}" />
<meta property="og:image" content="{og_image}" />
<meta property="og:site_name" content="Damus" />
<meta name="theme-color" content="#bd66ff" />
</head>
<body>
<div class="damus-app">
<header class="damus-header">
<a class="damus-logo-link" href="https://damus.io" target="_blank" rel="noopener noreferrer"><img class="damus-logo-image" src="/assets/logo_icon.png?v=2" alt="Damus" width="40" height="40" /></a>
<div class="damus-header-actions">
<a class="damus-link" href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>
</div>
</header>
<main class="damus-main">
<section class="damus-card">
<h1>404</h1>
<p class="damus-supporting">{message}</p>
<p class="damus-supporting">
Double-check the bech32 identifier in the URL, or head back to the <a href="/">homepage</a>.
</p>
</section>
</main>
<footer class="damus-footer">
<a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">Rendered by notecrumbs</a>
</footer>
</div>
</body>
</html>
"##,
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<Full<Bytes>> {
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<Full<Bytes>> {
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,
Expand Down
55 changes: 46 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
});
}
};

Expand Down Expand Up @@ -581,9 +584,9 @@ async fn serve(
} else if is_json {
match render_data {
RenderData::Note(note_rd) => html::serve_note_json(&app.ndb, &note_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 {
Expand Down Expand Up @@ -698,8 +701,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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")
Comment on lines +726 to +731

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't expose raw internal errors on .json requests.

Lines 726-731 return format!("{}\n", err) for every 500. That leaks internal error details to clients, and .json endpoints stop being JSON on the error path. Log the real error server-side and return a generic structured 500 instead.

Suggested fix
-                                        _ => {
-                                            error!("serve error (500): {}", err);
-                                            Response::builder()
-                                                .status(StatusCode::INTERNAL_SERVER_ERROR)
-                                                .body(Full::new(Bytes::from(format!("{}\n", err))))
-                                                .expect("building error response")
-                                        }
+                                        _ => {
+                                            error!("serve error (500): {}", err);
+                                            if wants_json {
+                                                Response::builder()
+                                                    .status(StatusCode::INTERNAL_SERVER_ERROR)
+                                                    .header(
+                                                        header::CONTENT_TYPE,
+                                                        "application/json; charset=utf-8",
+                                                    )
+                                                    .body(Full::new(Bytes::from(
+                                                        r#"{"error":"internal_server_error","message":"Internal server error"}"#,
+                                                    )))
+                                                    .expect("building error response")
+                                            } else {
+                                                Response::builder()
+                                                    .status(StatusCode::INTERNAL_SERVER_ERROR)
+                                                    .header(
+                                                        header::CONTENT_TYPE,
+                                                        "text/plain; charset=utf-8",
+                                                    )
+                                                    .body(Full::new(Bytes::from(
+                                                        "Internal server error\n",
+                                                    )))
+                                                    .expect("building error response")
+                                            }
+                                        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_ => {
error!("serve error (500): {}", err);
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Full::new(Bytes::from(format!("{}\n", err))))
.expect("building error response")
_ => {
error!("serve error (500): {}", err);
if wants_json {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(
header::CONTENT_TYPE,
"application/json; charset=utf-8",
)
.body(Full::new(Bytes::from(
r#"{"error":"internal_server_error","message":"Internal server error"}"#,
)))
.expect("building error response")
} else {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(
header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)
.body(Full::new(Bytes::from(
"Internal server error\n",
)))
.expect("building error response")
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.rs` around lines 726 - 731, In the generic error arm (`_ => { ...
}`) where `err` is currently interpolated into the response body, keep logging
the full `err` server-side but stop returning it to clients; instead detect if
the request expects JSON (inspect the request's Accept header or the request
path/extension) and, for JSON requests, return a 500 with a generic JSON body
(e.g. {"error":"internal_server_error"}) and Content-Type application/json,
otherwise return a generic plain-text 500 message; update the
`Response::builder()` call that currently uses `Bytes::from(format!("{}\n",
err))` to produce the sanitized payload while leaving the `error!` log
unchanged.

}
};
Ok(resp)
}
}
}
}),
)
.await
{
println!("Error serving connection: {:?}", err);
Expand Down
Loading