use askama::Template; use axum::extract::{Extension, Path, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum_login::tower_sessions::Session; use jiff::civil::Date; use crate::server::AppState; use crate::types::{Issue, SocialSource}; use crate::web::issue::{self, Download}; use crate::web::session::{AuthSession, Viewer}; use crate::web::{Html, Page, WebError}; /// The `` for the landing page before the first issue /// of the day exists. const NO_ISSUE_DESCRIPTION: &str = concat!( "The latest issue of The Daily EPUB is not out yet; ", "the next one lands tomorrow morning." ); /// The `` for the archive index. const ARCHIVE_DESCRIPTION: &str = concat!( "Every issue of The Daily EPUB, newest first: browse the archive by month ", "and read or download any past morning's paper." ); #[derive(Debug, Clone)] pub struct PublicIssue { pub date: Date, pub issue_number: i64, pub display_date: String, pub short_date: String, pub article_count: i64, pub reading_minutes: i64, pub stats_line: String, pub sections: Vec, pub generated_at: jiff::Timestamp, } #[derive(Debug, Clone)] pub struct PublicSection { pub name: String, pub entries: Vec, } #[derive(Debug, Clone)] pub struct PublicEntry { pub title: String, pub url: String, pub author: Option, pub source: String, pub domain: String, pub reading_minutes: i64, pub word_count: i64, pub summary: Option, pub why: Option, pub comment_links: Vec, pub is_lead: bool, } #[derive(Debug, Clone)] pub struct CommentLink { pub label: String, pub url: String, pub meta: String, } impl From<&Issue> for PublicIssue { fn from(issue: &Issue) -> Self { let sections = issue .lineup .section_order .iter() .map(|name| PublicSection { name: name.clone(), entries: issue .lineup .section_picks(name) .into_iter() .map(|pick| { let article = &pick.article; let mut comment_links: Vec = article .social .iter() .filter_map(|social| { let url = social.item_url.clone()?; let label = match social.source { SocialSource::Hn => "Hacker News", SocialSource::Lobsters => "Lobsters", SocialSource::Reddit => "Reddit", SocialSource::X => "X", }; Some(CommentLink { label: label.into(), url, meta: format!( "{} points · {} comments", social.score, social.num_comments ), }) }) .collect(); if let Some(url) = article.comments_url.as_ref().filter(|url| { *url != &article.canonical_url && *url != &article.url }) { comment_links.push(CommentLink { label: "Comments".into(), url: url.clone(), meta: String::new(), }); } PublicEntry { title: article.title.clone(), url: article.canonical_url.clone(), author: article.author.clone(), source: article.feed_title.clone(), domain: domain(&article.canonical_url), reading_minutes: article.reading_minutes(), word_count: article.word_count, summary: pick .summary .as_deref() .or_else(|| { issue .editorial .summaries .get(&article.id) .map(String::as_str) }) .map(str::trim) .filter(|summary| !summary.is_empty()) .map(str::to_string), why: pick.why.clone(), comment_links, is_lead: pick.is_lead, } }) .collect(), }) .collect(); Self { date: issue.meta.date, issue_number: issue.meta.issue_number, display_date: issue.meta.display_date.clone(), short_date: crate::pipeline::short_display_date(issue.meta.date), article_count: issue.meta.article_count, reading_minutes: issue.meta.reading_minutes, stats_line: issue.meta.stats_line(), sections, generated_at: issue.meta.generated_at, } } } impl PublicIssue { /// The `` for an issue page: the same counts the /// masthead prints, in a sentence a search result can show. fn description(&self) -> String { format!( "Issue No. {} for {}: {} articles across {} sections.", self.issue_number, self.display_date, self.article_count, self.sections.len(), ) } } fn domain(raw: &str) -> String { url::Url::parse(raw) .ok() .and_then(|url| url.host_str().map(str::to_string)) .map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string()) .unwrap_or_default() } #[derive(Template)] #[template(path = "issue_public.html")] struct IssuePublicTemplate { page: Page, issue: PublicIssue, downloads: Vec, empty: bool, } #[derive(Debug, Clone)] pub struct ArchiveMonth { pub label: String, pub issues: Vec, } #[derive(Debug, Clone)] pub struct ArchiveIssue { pub date: Date, pub display_date: String, pub issue_number: i64, pub article_count: i64, } #[derive(Template)] #[template(path = "issue_list.html")] struct IssueListTemplate { page: Page, months: Vec, } #[derive(Template)] #[template(path = "feed_entry.html")] struct FeedEntryTemplate<'a> { issue: &'a PublicIssue, } pub async fn latest( State(state): State, auth: AuthSession, Extension(session): Extension, headers: HeaderMap, ) -> Result { let Some(date) = state.db.latest_issue_date().await? else { let viewer = auth.user().await.map(Viewer::from); let response = Html(IssuePublicTemplate { page: Page::new("Latest issue", viewer, "latest") .with_description(NO_ISSUE_DESCRIPTION), issue: empty_issue(), downloads: Vec::new(), empty: true, }) .into_response(); return Ok(public_cache(response, &headers)); }; show_issue(State(state), auth, Extension(session), headers, Path(date)).await } pub async fn show_issue( State(state): State, auth: AuthSession, Extension(session): Extension, headers: HeaderMap, Path(date): Path, ) -> Result { let Some(view) = issue::load(&state.db, &state.config(), date).await? else { return Err(WebError::NotFound); }; let viewer = auth.user().await.map(Viewer::from); if let Some(viewer) = viewer { let response = issue::render_full(&state, view, viewer, &session).await?; return Ok(public_cache(response, &headers)); } let active_nav = if view.is_latest { "latest" } else { "archive" }; let issue = PublicIssue::from(&view.issue); let response = Html(IssuePublicTemplate { page: Page::new(format!("Issue {date}"), None, active_nav) .with_description(issue.description()), issue, downloads: Vec::new(), empty: false, }) .into_response(); Ok(public_cache(response, &headers)) } pub async fn archive( State(state): State, auth: AuthSession, headers: HeaderMap, ) -> Result { let rows = state.db.issue_dates(None).await?; let mut months: Vec = Vec::new(); for row in rows { let key = format!("{:04}-{:02}", row.date.year(), row.date.month()); if months.last().map(|month| month.label.as_str()) != Some(key.as_str()) { months.push(ArchiveMonth { label: key, issues: Vec::new(), }); } if let Some(month) = months.last_mut() { month.issues.push(ArchiveIssue { date: row.date, display_date: crate::pipeline::display_date(row.date), issue_number: row.issue_number, article_count: row.article_count, }); } } let response = Html(IssueListTemplate { page: Page::new( "Issue archive", auth.user().await.map(Viewer::from), "archive", ) .with_description(ARCHIVE_DESCRIPTION), months, }) .into_response(); Ok(public_cache(response, &headers)) } pub async fn feed(State(state): State) -> Result { let config = state.config(); let rows = state.db.issue_dates(Some(30)).await?; let mut entries = String::new(); let mut updated = jiff::Timestamp::UNIX_EPOCH; for row in rows { let Some(view) = issue::load(&state.db, &config, row.date).await? else { continue; }; updated = updated.max(view.issue.meta.generated_at); let issue = PublicIssue::from(&view.issue); let content = FeedEntryTemplate { issue: &issue } .render() .map_err(|error| WebError::Internal(error.into()))?; let href = format!( "{}/issues/{}", config.server.public_url.trim_end_matches('/'), issue.date ); entries.push_str(&format!( "tag:{},{}:issue/{}The Daily EPUB — {}{}{}", feed_host(&config.server.public_url), issue.date.year(), issue.date, issue.date, issue.generated_at, xml_escape(&href), xml_escape(&content), )); } let home = config.server.public_url.trim_end_matches('/'); let body = format!( "{}The Daily EPUB{}{}", xml_escape(home), updated, xml_escape(home), entries ); Ok(( StatusCode::OK, [ (header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"), (header::CACHE_CONTROL, PUBLIC_CACHE), ], body, ) .into_response()) } pub async fn robots() -> Response { ( StatusCode::OK, [ (header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=86400"), ], "User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n", ) .into_response() } /// `Cache-Control` for an anonymous public page (§3.12). /// /// Five minutes, for browsers and shared caches alike. An issue changes once a /// day, so the only staleness this allows is the few minutes after a publish, /// and the operator never sees even that: signed-in requests get /// [`PRIVATE_CACHE`]. A longer shared-cache age was tried and rejected: it /// needs a purge call after every publish to stay correct, and at this site's /// traffic an edge rarely keeps a page long enough for the extra age to pay /// for that machinery. pub const PUBLIC_CACHE: &str = "public, max-age=300"; /// `Cache-Control` for anything a signed-in reader sees, and for every /// authenticated download. `private` keeps it out of shared caches even if a /// CDN cache rule is misconfigured; `no-store` keeps it off disk. pub const PRIVATE_CACHE: &str = "private, no-store"; fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response { let value = if request_headers .get(header::COOKIE) .and_then(|value| value.to_str().ok()) .is_some_and(|cookies| cookies.contains("daily_session=")) { PRIVATE_CACHE } else { PUBLIC_CACHE }; response.headers_mut().insert( header::CACHE_CONTROL, axum::http::HeaderValue::from_static(value), ); response } fn feed_host(public_url: &str) -> String { url::Url::parse(public_url) .ok() .and_then(|url| url.host_str().map(str::to_string)) .unwrap_or_else(|| "daily.hallada.net".into()) } fn xml_escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn empty_issue() -> PublicIssue { PublicIssue { date: "1970-01-01".parse().expect("valid epoch date"), issue_number: 0, display_date: String::new(), short_date: String::new(), article_count: 0, reading_minutes: 0, stats_line: String::new(), sections: Vec::new(), generated_at: jiff::Timestamp::UNIX_EPOCH, } } #[cfg(test)] mod tests { use super::*; #[test] fn public_issue_shows_summaries_and_why_but_no_bodies() { let source = crate::epub::fixtures::issue(); let public = PublicIssue::from(&source); let html = FeedEntryTemplate { issue: &public }.render().unwrap(); assert!(html.contains("The Lead Story")); assert!(html.contains("Hacker News")); assert!(html.contains("What it argues, and why it is worth the time.")); assert!(html.contains("A short abstract for the second piece.")); assert!(html.contains("The systems story with enough operational detail to matter")); assert!(html.contains("A small-scene delight outside the usual technical orbit")); for private in [ "Two stories today", "Body of", "write path", "Agreed", "Something happened", "concise view of the day", ] { assert!(!html.contains(private), "leaked {private:?} in {html}"); } } }