html.rs (21112B)
1 use crate::Error; 2 use crate::{ 3 abbrev::{abbrev_str, abbreviate}, 4 render::{NoteAndProfileRenderData, ProfileRenderData}, 5 Notecrumbs, 6 }; 7 use ammonia::Builder as HtmlSanitizer; 8 use http_body_util::Full; 9 use hyper::{body::Bytes, header, Request, Response, StatusCode}; 10 use nostr_sdk::prelude::{Nip19, ToBech32}; 11 use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, Transaction}; 12 use pulldown_cmark::{html, Options, Parser}; 13 use std::fmt::Write as _; 14 use std::io::Write; 15 use std::str::FromStr; 16 17 fn blocktype_name(blocktype: &BlockType) -> &'static str { 18 match blocktype { 19 BlockType::MentionBech32 => "mention", 20 BlockType::Hashtag => "hashtag", 21 BlockType::Url => "url", 22 BlockType::Text => "text", 23 BlockType::MentionIndex => "indexed_mention", 24 BlockType::Invoice => "invoice", 25 } 26 } 27 28 #[derive(Default)] 29 struct ArticleMetadata { 30 title: Option<String>, 31 image: Option<String>, 32 summary: Option<String>, 33 published_at: Option<u64>, 34 topics: Vec<String>, 35 } 36 37 fn collapse_whitespace<S: AsRef<str>>(input: S) -> String { 38 let mut result = String::with_capacity(input.as_ref().len()); 39 let mut last_space = false; 40 for ch in input.as_ref().chars() { 41 if ch.is_whitespace() { 42 if !last_space && !result.is_empty() { 43 result.push(' '); 44 last_space = true; 45 } 46 } else { 47 result.push(ch); 48 last_space = false; 49 } 50 } 51 52 result.trim().to_string() 53 } 54 55 fn extract_article_metadata(note: &Note) -> ArticleMetadata { 56 let mut meta = ArticleMetadata::default(); 57 58 for tag in note.tags() { 59 let mut iter = tag.into_iter(); 60 let Some(tag_kind) = iter.next().and_then(|nstr| nstr.variant().str()) else { 61 continue; 62 }; 63 64 match tag_kind { 65 "title" => { 66 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { 67 meta.title = Some(value.to_owned()); 68 } 69 } 70 "image" => { 71 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { 72 meta.image = Some(value.to_owned()); 73 } 74 } 75 "summary" => { 76 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { 77 meta.summary = Some(value.to_owned()); 78 } 79 } 80 "published_at" => { 81 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { 82 if let Ok(ts) = u64::from_str(value) { 83 meta.published_at = Some(ts); 84 } 85 } 86 } 87 "t" => { 88 for topic in iter { 89 if let Some(value) = topic.variant().str() { 90 if !value.is_empty() 91 && !meta 92 .topics 93 .iter() 94 .any(|existing| existing.eq_ignore_ascii_case(value)) 95 { 96 meta.topics.push(value.to_owned()); 97 } 98 if meta.topics.len() >= 10 { 99 break; 100 } 101 } 102 } 103 } 104 _ => {} 105 } 106 } 107 108 meta 109 } 110 111 fn render_markdown(markdown: &str) -> String { 112 let mut options = Options::empty(); 113 options.insert(Options::ENABLE_TABLES); 114 options.insert(Options::ENABLE_FOOTNOTES); 115 options.insert(Options::ENABLE_STRIKETHROUGH); 116 options.insert(Options::ENABLE_TASKLISTS); 117 118 let parser = Parser::new_ext(markdown, options); 119 let mut html_buf = String::new(); 120 html::push_html(&mut html_buf, parser); 121 122 HtmlSanitizer::default().clean(&html_buf).to_string() 123 } 124 125 pub fn serve_note_json( 126 ndb: &Ndb, 127 note_rd: &NoteAndProfileRenderData, 128 ) -> Result<Response<Full<Bytes>>, Error> { 129 let mut body: Vec<u8> = vec![]; 130 131 let txn = Transaction::new(ndb)?; 132 133 let note = match note_rd.note_rd.lookup(&txn, ndb) { 134 Ok(note) => note, 135 Err(_) => return Err(Error::NotFound), 136 }; 137 138 let note_key = match note.key() { 139 Some(note_key) => note_key, 140 None => return Err(Error::NotFound), 141 }; 142 143 write!(body, "{{\"note\":{},\"parsed_content\":[", ¬e.json()?)?; 144 145 if let Ok(blocks) = ndb.get_blocks_by_key(&txn, note_key) { 146 for (i, block) in blocks.iter(¬e).enumerate() { 147 if i != 0 { 148 write!(body, ",")?; 149 } 150 write!( 151 body, 152 "{{\"{}\":{}}}", 153 blocktype_name(&block.blocktype()), 154 serde_json::to_string(block.as_str())? 155 )?; 156 } 157 }; 158 159 write!(body, "]")?; 160 161 if let Ok(results) = ndb.query( 162 &txn, 163 &[Filter::new() 164 .authors([note.pubkey()]) 165 .kinds([0]) 166 .limit(1) 167 .build()], 168 1, 169 ) { 170 if let Some(profile_note) = results.first() { 171 write!(body, ",\"profile\":{}", profile_note.note.json()?)?; 172 } 173 } 174 175 writeln!(body, "}}")?; 176 177 Ok(Response::builder() 178 .header(header::CONTENT_TYPE, "application/json; charset=utf-8") 179 .status(StatusCode::OK) 180 .body(Full::new(Bytes::from(body)))?) 181 } 182 183 pub fn render_note_content(body: &mut Vec<u8>, note: &Note, blocks: &Blocks) { 184 for block in blocks.iter(note) { 185 match block.blocktype() { 186 BlockType::Url => { 187 let url = html_escape::encode_text(block.as_str()); 188 let _ = write!(body, r#"<a href="{}">{}</a>"#, url, url); 189 } 190 191 BlockType::Hashtag => { 192 let hashtag = html_escape::encode_text(block.as_str()); 193 let _ = write!(body, r#"<span class="hashtag">#{}</span>"#, hashtag); 194 } 195 196 BlockType::Text => { 197 let text = html_escape::encode_text(block.as_str()).replace("\n", "<br/>"); 198 let _ = write!(body, r"{}", text); 199 } 200 201 BlockType::Invoice => { 202 let _ = write!(body, r"{}", block.as_str()); 203 } 204 205 BlockType::MentionIndex => { 206 let _ = write!(body, r"@nostrich"); 207 } 208 209 BlockType::MentionBech32 => { 210 match block.as_mention().unwrap() { 211 Mention::Event(_) 212 | Mention::Note(_) 213 | Mention::Profile(_) 214 | Mention::Pubkey(_) 215 | Mention::Secret(_) 216 | Mention::Addr(_) => { 217 let _ = write!( 218 body, 219 r#"<a href="/{}">@{}</a>"#, 220 block.as_str(), 221 &abbrev_str(block.as_str()) 222 ); 223 } 224 225 Mention::Relay(relay) => { 226 let _ = write!( 227 body, 228 r#"<a href="/{}">{}</a>"#, 229 block.as_str(), 230 &abbrev_str(relay.as_str()) 231 ); 232 } 233 }; 234 } 235 }; 236 } 237 } 238 239 fn build_note_content_html( 240 app: &Notecrumbs, 241 note: &Note, 242 txn: &Transaction, 243 author_display: &str, 244 pfp_url: &str, 245 timestamp_value: u64, 246 ) -> String { 247 let mut body_buf = Vec::new(); 248 if let Some(blocks) = note 249 .key() 250 .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()) 251 { 252 render_note_content(&mut body_buf, note, &blocks); 253 } else { 254 let _ = write!(body_buf, "{}", html_escape::encode_text(note.content())); 255 } 256 257 let note_body = String::from_utf8(body_buf).unwrap_or_default(); 258 let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url); 259 let timestamp_attr = timestamp_value.to_string(); 260 261 format!( 262 r#"<div class="note"> 263 <div class="note-header"> 264 <img src="{pfp}" class="note-author-avatar" /> 265 <div class="note-author-name">{author}</div> 266 <div class="note-header-separator">·</div> 267 <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> 268 </div> 269 <div class="note-content">{body}</div> 270 </div>"#, 271 pfp = pfp_attr, 272 author = author_display, 273 ts = timestamp_attr, 274 body = note_body 275 ) 276 } 277 278 fn build_article_content_html( 279 author_display: &str, 280 pfp_url: &str, 281 timestamp_value: u64, 282 article_title_html: &str, 283 hero_image: Option<&str>, 284 summary_html: Option<&str>, 285 article_body_html: &str, 286 topics: &[String], 287 ) -> String { 288 let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url); 289 let timestamp_attr = timestamp_value.to_string(); 290 291 let hero_markup = hero_image 292 .filter(|url| !url.is_empty()) 293 .map(|url| { 294 let url_attr = html_escape::encode_double_quoted_attribute(url); 295 format!( 296 r#"<img src="{url}" class="article-hero" alt="Article header image" />"#, 297 url = url_attr 298 ) 299 }) 300 .unwrap_or_default(); 301 302 let summary_markup = summary_html 303 .map(|summary| format!(r#"<p class="article-summary">{}</p>"#, summary)) 304 .unwrap_or_default(); 305 306 let mut topics_markup = String::new(); 307 if !topics.is_empty() { 308 topics_markup.push_str(r#"<div class="article-topics">"#); 309 for topic in topics { 310 if topic.is_empty() { 311 continue; 312 } 313 let topic_text = html_escape::encode_text(topic); 314 let _ = write!( 315 topics_markup, 316 r#"<span class="article-topic">#{}</span>"#, 317 topic_text 318 ); 319 } 320 topics_markup.push_str("</div>"); 321 } 322 323 format!( 324 r#"<div class="note article-note"> 325 <div class="note-header"> 326 <img src="{pfp}" class="note-author-avatar" /> 327 <div class="note-author-name">{author}</div> 328 <div class="note-header-separator">·</div> 329 <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> 330 </div> 331 <h1 class="article-title">{title}</h1> 332 {hero} 333 {summary} 334 {topics} 335 <div class="article-content">{body}</div> 336 </div>"#, 337 pfp = pfp_attr, 338 author = author_display, 339 ts = timestamp_attr, 340 title = article_title_html, 341 hero = hero_markup, 342 summary = summary_markup, 343 topics = topics_markup, 344 body = article_body_html 345 ) 346 } 347 348 const LOCAL_TIME_SCRIPT: &str = r#" 349 <script> 350 (function() { 351 'use strict'; 352 if (!('Intl' in window) || typeof Intl.DateTimeFormat !== 'function') { 353 return; 354 } 355 var nodes = document.querySelectorAll('[data-timestamp]'); 356 var displayFormatter = new Intl.DateTimeFormat(undefined, { 357 hour: 'numeric', 358 minute: '2-digit', 359 timeZoneName: 'short' 360 }); 361 var titleFormatter = new Intl.DateTimeFormat(undefined, { 362 year: 'numeric', 363 month: 'short', 364 day: 'numeric', 365 hour: 'numeric', 366 minute: '2-digit', 367 second: '2-digit', 368 timeZoneName: 'long' 369 }); 370 var monthNames = [ 371 'Jan.', 372 'Feb.', 373 'Mar.', 374 'Apr.', 375 'May', 376 'Jun.', 377 'Jul.', 378 'Aug.', 379 'Sep.', 380 'Oct.', 381 'Nov.', 382 'Dec.' 383 ]; 384 Array.prototype.forEach.call(nodes, function(node) { 385 var raw = node.getAttribute('data-timestamp'); 386 if (!raw) { 387 return; 388 } 389 var timestamp = Number(raw); 390 if (!isFinite(timestamp)) { 391 return; 392 } 393 var date = new Date(timestamp * 1000); 394 if (isNaN(date.getTime())) { 395 return; 396 } 397 var shortText = displayFormatter.format(date); 398 var month = monthNames[date.getMonth()] || ''; 399 var day = String(date.getDate()); 400 var formattedDate = month 401 ? month + ' ' + day + ', ' + date.getFullYear() 402 : day + ', ' + date.getFullYear(); 403 var combined = formattedDate + ' · ' + shortText; 404 node.textContent = combined; 405 node.setAttribute('title', titleFormatter.format(date)); 406 node.setAttribute('datetime', date.toISOString()); 407 }); 408 }()); 409 </script> 410 "#; 411 412 pub fn serve_note_html( 413 app: &Notecrumbs, 414 nip19: &Nip19, 415 note_rd: &NoteAndProfileRenderData, 416 _r: Request<hyper::body::Incoming>, 417 ) -> Result<Response<Full<Bytes>>, Error> { 418 let mut data = Vec::new(); 419 420 let txn = Transaction::new(&app.ndb)?; 421 422 let note = match note_rd.note_rd.lookup(&txn, &app.ndb) { 423 Ok(note) => note, 424 Err(_) => return Err(Error::NotFound), 425 }; 426 427 let profile_record = note_rd 428 .profile_rd 429 .as_ref() 430 .and_then(|profile_rd| match profile_rd { 431 ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(), 432 ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(), 433 }); 434 435 let profile_data = profile_record 436 .as_ref() 437 .and_then(|record| record.record().profile()); 438 439 let profile_name_raw = profile_data 440 .and_then(|profile| profile.name()) 441 .unwrap_or("nostrich"); 442 let profile_name_html = html_escape::encode_text(profile_name_raw).into_owned(); 443 444 let default_pfp_url = "https://damus.io/img/no-profile.svg"; 445 let pfp_url_raw = profile_data 446 .and_then(|profile| profile.picture()) 447 .unwrap_or(default_pfp_url); 448 449 let hostname = "https://damus.io"; 450 let bech32 = nip19.to_bech32().unwrap(); 451 let canonical_url = format!("{}/{}", hostname, bech32); 452 let fallback_image_url = format!("{}/{}.png", hostname, bech32); 453 454 let mut display_title_raw = profile_name_raw.to_string(); 455 let mut og_description_raw = collapse_whitespace(abbreviate(note.content(), 64)); 456 let mut og_image_url_raw = fallback_image_url.clone(); 457 let mut timestamp_value = note.created_at(); 458 let mut page_heading = "Note"; 459 let mut og_type = "website"; 460 let author_display_html = profile_name_html.clone(); 461 462 let main_content_html = if matches!(note.kind(), 30023 | 30024) { 463 page_heading = "Article"; 464 og_type = "article"; 465 466 let ArticleMetadata { 467 title, 468 image, 469 summary, 470 published_at, 471 topics, 472 } = extract_article_metadata(¬e); 473 474 if let Some(title) = title 475 .as_deref() 476 .map(|value| value.trim()) 477 .filter(|value| !value.is_empty()) 478 { 479 display_title_raw = title.to_owned(); 480 } 481 482 if let Some(published_at) = published_at { 483 timestamp_value = published_at; 484 } 485 486 let summary_source = summary 487 .as_deref() 488 .map(|value| value.trim()) 489 .filter(|value| !value.is_empty()) 490 .map(|value| value.to_owned()) 491 .unwrap_or_else(|| abbreviate(note.content(), 240).to_string()); 492 493 if let Some(ref image_url) = image { 494 if !image_url.trim().is_empty() { 495 og_image_url_raw = image_url.trim().to_owned(); 496 } 497 } 498 499 og_description_raw = collapse_whitespace(&summary_source); 500 501 let article_title_html = html_escape::encode_text(&display_title_raw).into_owned(); 502 let summary_display_html = if summary_source.is_empty() { 503 None 504 } else { 505 Some(html_escape::encode_text(&summary_source).into_owned()) 506 }; 507 let article_body_html = render_markdown(note.content()); 508 509 build_article_content_html( 510 author_display_html.as_str(), 511 pfp_url_raw, 512 timestamp_value, 513 &article_title_html, 514 image.as_deref(), 515 summary_display_html.as_deref(), 516 &article_body_html, 517 &topics, 518 ) 519 } else { 520 build_note_content_html( 521 app, 522 ¬e, 523 &txn, 524 author_display_html.as_str(), 525 pfp_url_raw, 526 timestamp_value, 527 ) 528 }; 529 530 if og_description_raw.is_empty() { 531 og_description_raw = display_title_raw.clone(); 532 } 533 534 if og_image_url_raw.trim().is_empty() { 535 og_image_url_raw = fallback_image_url; 536 } 537 538 let page_title_text = format!("{} on nostr", display_title_raw); 539 let og_image_alt_text = format!("{}: {}", display_title_raw, og_description_raw); 540 541 let page_title_html = html_escape::encode_text(&page_title_text).into_owned(); 542 let page_heading_html = html_escape::encode_text(page_heading).into_owned(); 543 let og_description_attr = 544 html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned(); 545 let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned(); 546 let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned(); 547 let og_image_alt_attr = 548 html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned(); 549 let canonical_url_attr = 550 html_escape::encode_double_quoted_attribute(&canonical_url).into_owned(); 551 552 let _ = write!( 553 data, 554 r#" 555 <html> 556 <head> 557 <title>{page_title}</title> 558 <link rel="stylesheet" href="https://damus.io/css/notecrumbs.css" type="text/css" /> 559 <meta name="viewport" content="width=device-width, initial-scale=1"> 560 <meta name="apple-itunes-app" content="app-id=1628663131, app-argument=damus:nostr:{bech32}"/> 561 <meta charset="UTF-8"> 562 <meta property="og:description" content="{og_description}" /> 563 <meta property="og:image" content="{og_image}"/> 564 <meta property="og:image:alt" content="{og_image_alt}" /> 565 <meta property="og:image:height" content="600" /> 566 <meta property="og:image:width" content="1200" /> 567 <meta property="og:image:type" content="image/png" /> 568 <meta property="og:site_name" content="Damus" /> 569 <meta property="og:title" content="{og_title}" /> 570 <meta property="og:url" content="{canonical_url}"/> 571 <meta property="og:type" content="{og_type}"/> 572 <meta name="og:type" content="{og_type}"/> 573 <meta name="twitter:image:src" content="{og_image}" /> 574 <meta name="twitter:site" content="@damusapp" /> 575 <meta name="twitter:card" content="summary_large_image" /> 576 <meta name="twitter:title" content="{og_title}" /> 577 <meta name="twitter:description" content="{og_description}" /> 578 </head> 579 <body> 580 <main> 581 <div class="container"> 582 <div class="top-menu"> 583 <a href="https://damus.io" target="_blank"> 584 <img src="https://damus.io/logo_icon.png" class="logo" /> 585 </a> 586 </div> 587 <h3 class="page-heading">{page_heading}</h3> 588 <div class="note-container"> 589 {main_content} 590 </div> 591 </div> 592 <div class="note-actions-footer"> 593 <a href="nostr:{bech32}" class="muted-link">Open with default Nostr client</a> 594 </div> 595 </main> 596 <footer> 597 <span class="footer-note"> 598 <a href="https://damus.io">Damus</a> is a decentralized social network app built on the Nostr protocol. 599 </span> 600 <span class="copyright-note"> 601 © Damus Nostr Inc. 602 </span> 603 </footer> 604 {script} 605 </body> 606 </html> 607 "#, 608 page_title = page_title_html, 609 og_description = og_description_attr, 610 og_image = og_image_attr, 611 og_image_alt = og_image_alt_attr, 612 og_title = og_title_attr, 613 canonical_url = canonical_url_attr, 614 og_type = og_type, 615 page_heading = page_heading_html, 616 main_content = main_content_html, 617 bech32 = bech32, 618 script = LOCAL_TIME_SCRIPT, 619 ); 620 621 Ok(Response::builder() 622 .header(header::CONTENT_TYPE, "text/html") 623 .status(StatusCode::OK) 624 .body(Full::new(Bytes::from(data)))?) 625 }