The edge-purge integration was reverted in the previous commit; this finishes the job. Public pages, the feed and /issues.json now say `public, max-age=300` for browsers and shared caches alike, the code comments and README explain why the longer s-maxage plus purge design was dropped, and the rollout runbook loses its purge step, marks the HTML cache rule optional, and warns to keep the record DNS-only until the Universal SSL certificate is active. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
451 lines
15 KiB
Rust
451 lines
15 KiB
Rust
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 `<meta name="description">` 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 `<meta name="description">` 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<PublicSection>,
|
|
pub generated_at: jiff::Timestamp,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PublicSection {
|
|
pub name: String,
|
|
pub entries: Vec<PublicEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PublicEntry {
|
|
pub title: String,
|
|
pub url: String,
|
|
pub author: Option<String>,
|
|
pub source: String,
|
|
pub domain: String,
|
|
pub reading_minutes: i64,
|
|
pub word_count: i64,
|
|
pub summary: Option<String>,
|
|
pub why: Option<String>,
|
|
pub comment_links: Vec<CommentLink>,
|
|
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<CommentLink> = 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 `<meta name="description">` 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<Download>,
|
|
empty: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ArchiveMonth {
|
|
pub label: String,
|
|
pub issues: Vec<ArchiveIssue>,
|
|
}
|
|
|
|
#[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<ArchiveMonth>,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "feed_entry.html")]
|
|
struct FeedEntryTemplate<'a> {
|
|
issue: &'a PublicIssue,
|
|
}
|
|
|
|
pub async fn latest(
|
|
State(state): State<AppState>,
|
|
auth: AuthSession,
|
|
Extension(session): Extension<Session>,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, WebError> {
|
|
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<AppState>,
|
|
auth: AuthSession,
|
|
Extension(session): Extension<Session>,
|
|
headers: HeaderMap,
|
|
Path(date): Path<Date>,
|
|
) -> Result<Response, WebError> {
|
|
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<AppState>,
|
|
auth: AuthSession,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, WebError> {
|
|
let rows = state.db.issue_dates(None).await?;
|
|
let mut months: Vec<ArchiveMonth> = 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<AppState>) -> Result<Response, WebError> {
|
|
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!(
|
|
"<entry><id>tag:{},{}:issue/{}</id><title>The Daily EPUB — {}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
|
|
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!(
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"><id>{}</id><title>The Daily EPUB</title><updated>{}</updated><link rel=\"self\" href=\"{}/feed.xml\"/>{}</feed>",
|
|
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}");
|
|
}
|
|
}
|
|
}
|