notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

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\":[", &note.json()?)?;
    144 
    145     if let Ok(blocks) = ndb.get_blocks_by_key(&txn, note_key) {
    146         for (i, block) in blocks.iter(&note).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(&note);
    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             &note,
    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 }