Web dashboard step 2: full issue pages, article chapters, POST /rate
Signed-in issue page with the Brief, downloads, index and colophon; article, World Briefing and Behind the paper pages under the login guard; the rating widget with its fetch enhancement; the admin-only POST /rate writing dashboard-attributed rating events; the HMAC confirmation page links to the site. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# Step 2 handoff — full issues and ratings
|
||||
|
||||
## Landed
|
||||
|
||||
- Signed-in `/` and `/issues/{date}` now render the full issue: Brief, download
|
||||
links for artifacts that still exist, the section/article index with summaries
|
||||
and `why`, admin rating widgets, World/Behind links when their snapshot data is
|
||||
present, and colophon facts.
|
||||
- Added login-protected article, World Briefing, and Behind the paper pages.
|
||||
Article pages use the EPUB's ammonia cleaning path without XHTML conversion,
|
||||
retain remote images with lazy/no-referrer attributes, include rendered
|
||||
discussions, previous/next navigation, source links, and admin ratings.
|
||||
- Replaced the Step 1 `/rate` stub with the admin-only form/JSON handler. Events
|
||||
are append-only `dashboard` events attributed to the viewer; missing issue
|
||||
dates use `latest_issue_date_for_article`; clear events use the CLI's exact
|
||||
`cleared`/`0.0` representation; form redirects validate `next` and carry a
|
||||
flash, while JSON returns the event id.
|
||||
- Added the no-JS rating partial and JavaScript enhancement, active-state
|
||||
updates, issue/article styling, and the signed-in private cache policy.
|
||||
- The e-ink HMAC confirmation page now links to the corresponding site issue.
|
||||
- Added router tests for full/fallback rendering, article discussions and image
|
||||
handling, World/Behind pages, 404s and login protection, artifact gating,
|
||||
both rating representations, role guards, attribution, fallback dates,
|
||||
validated redirects, clear values, admin widget state, and near-miss links.
|
||||
|
||||
## Deviations and notes
|
||||
|
||||
- The pinned `axum-login` dependency disables tower-sessions' `axum-core`
|
||||
feature, so its re-exported `Session` does not implement an Axum extractor in
|
||||
this dependency graph. Handlers use `Extension<Session>` to read the exact
|
||||
session already installed by the auth layer; no second tower-sessions
|
||||
dependency was added.
|
||||
- The web form and JSON response use `down`, as specified for the widget, while
|
||||
the persisted event label is `not_for_me`, matching `cmd_ratings` and the
|
||||
existing learned-rating queries exactly.
|
||||
- No migration was needed. Step 3 can construct `RatingWidget` with
|
||||
`show_note = true` for dashboard article variants.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo fmt`: pass.
|
||||
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||
- Focused `cargo test web:: -- --nocapture`: **22 passed, 0 failed**.
|
||||
- `cargo test` with the documented sandbox listener tests skipped:
|
||||
**395 passed, 0 failed, 15 filtered out**. The filtered tests were the four
|
||||
Anthropic listener tests, three OpenAI fake-server listener tests noted in the
|
||||
Step 1 handoff review, the relative-URL listener test, five `server::tests`
|
||||
listener tests, and both `tests/m7_server.rs` tests.
|
||||
|
||||
## Orchestrator review (2026-09-03)
|
||||
|
||||
- Accepted as is. `Extension<Session>` for flashes is fine (the auth layer's
|
||||
session manager inserts it); no second tower-sessions dependency.
|
||||
- Cosmetic follow-up for step 7: download buttons show raw byte counts;
|
||||
render them human-readable (KB/MB).
|
||||
- Full suite outside the sandbox: 376 lib + all integration tests green.
|
||||
+29
-3
@@ -591,10 +591,17 @@ fn confirmation_page(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let issue_url = format!(
|
||||
"{}/issues/{date}",
|
||||
config.server.public_url.trim_end_matches('/')
|
||||
);
|
||||
let body = page_html(
|
||||
message,
|
||||
None,
|
||||
Some(&format!("<p><small>Change it: {choices}</small></p>")),
|
||||
Some(&format!(
|
||||
"<p><small>Change it: {choices}</small></p><p><small><a href=\"{}\">Open this issue on the site</a></small></p>",
|
||||
escape_attr(&issue_url)
|
||||
)),
|
||||
);
|
||||
(
|
||||
status,
|
||||
@@ -774,8 +781,8 @@ mod tests {
|
||||
assert!(safe_join(dir, "../../etc/passwd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_confirmation_page_is_tiny_and_self_contained() {
|
||||
#[tokio::test]
|
||||
async fn the_confirmation_page_is_tiny_and_self_contained() {
|
||||
let html = page_html(
|
||||
"Recorded: Loved it — thanks.",
|
||||
Some("2026-08-15 · article 42"),
|
||||
@@ -790,6 +797,25 @@ mod tests {
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert!(page_html("<b>x</b>", None, None).contains("<b>"));
|
||||
|
||||
let mut config = Config::default();
|
||||
config.server.hmac_secret = Some("test-secret".into());
|
||||
config.server.public_url = "https://daily.example".into();
|
||||
let response =
|
||||
confirmation_page(StatusCode::OK, "Recorded", &config, date(), 42, Vote::Loved);
|
||||
let body = String::from_utf8(
|
||||
axum::body::to_bytes(response.into_body(), 4096)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
body.contains(
|
||||
"href=\"https://daily.example/issues/2026-08-15\">Open this issue on the site"
|
||||
),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
+903
-10
@@ -1,13 +1,24 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum_login::tower_sessions::Session;
|
||||
use jiff::civil::Date;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::epub::chapters;
|
||||
use crate::pipeline::display_date;
|
||||
use crate::types::{BehindThePaper, Colophon, Editorial, Issue, IssueMeta, Lineup, Pick};
|
||||
use crate::server::AppState;
|
||||
use crate::types::{
|
||||
ArticleId, BehindThePaper, Colophon, Edition, Editorial, Issue, IssueMeta, Lineup, Pick,
|
||||
};
|
||||
use crate::web::rate::{self, RatingWidget};
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Html, Page, WebError, take_flash};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Download {
|
||||
@@ -132,12 +143,30 @@ pub async fn load(
|
||||
};
|
||||
issue.meta.article_count = issue.lineup.picks.len() as i64;
|
||||
let downloads = [
|
||||
("EPUB", row.epub_path.as_deref(), "epub"),
|
||||
("X4 EPUB", row.x4_path.as_deref(), "epub"),
|
||||
("XTC", row.xtc_path.as_deref(), "xtc"),
|
||||
(
|
||||
"EPUB",
|
||||
row.epub_path.as_deref(),
|
||||
Some(config.publish.epub_dir.join(crate::publish::issue_filename(
|
||||
date,
|
||||
Edition::Standard,
|
||||
"epub",
|
||||
))),
|
||||
"epub",
|
||||
),
|
||||
(
|
||||
"X4 EPUB",
|
||||
row.x4_path.as_deref(),
|
||||
Some(config.publish.epub_dir.join(crate::publish::issue_filename(
|
||||
date,
|
||||
Edition::X4,
|
||||
"epub",
|
||||
))),
|
||||
"epub",
|
||||
),
|
||||
("XTC", row.xtc_path.as_deref(), None, "xtc"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, raw, kind)| download(label, raw?, kind))
|
||||
.filter_map(|(label, raw, fallback, kind)| download(label, raw, fallback, kind))
|
||||
.collect();
|
||||
Ok(Some(IssueView {
|
||||
issue,
|
||||
@@ -146,8 +175,17 @@ pub async fn load(
|
||||
}))
|
||||
}
|
||||
|
||||
fn download(label: &str, raw: &str, kind: &str) -> Option<Download> {
|
||||
let path = Path::new(raw);
|
||||
fn download(
|
||||
label: &str,
|
||||
raw: Option<&str>,
|
||||
fallback: Option<PathBuf>,
|
||||
kind: &str,
|
||||
) -> Option<Download> {
|
||||
let path = raw
|
||||
.map(FsPath::new)
|
||||
.filter(|path| path.is_file())
|
||||
.map(FsPath::to_path_buf)
|
||||
.or_else(|| fallback.filter(|path| path.is_file()))?;
|
||||
let metadata = path.metadata().ok()?;
|
||||
let name = path.file_name()?.to_str()?;
|
||||
Some(Download {
|
||||
@@ -157,16 +195,487 @@ fn download(label: &str, raw: &str, kind: &str) -> Option<Download> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FullEntry {
|
||||
title: String,
|
||||
href: String,
|
||||
source: String,
|
||||
reading_minutes: i64,
|
||||
summary: String,
|
||||
why: Option<String>,
|
||||
rating: Option<RatingWidget>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FullSection {
|
||||
name: String,
|
||||
entries: Vec<FullEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CostLine {
|
||||
provider: String,
|
||||
cost: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColophonView {
|
||||
generated_at: String,
|
||||
bulk_model: String,
|
||||
editor_model: String,
|
||||
summaries_model: String,
|
||||
provider_costs: Vec<CostLine>,
|
||||
entries_fetched: i64,
|
||||
feeds_seen: i64,
|
||||
candidates: i64,
|
||||
article_count: i64,
|
||||
section_count: i64,
|
||||
total_words: String,
|
||||
reading_minutes: i64,
|
||||
cost_usd: String,
|
||||
generator_version: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "issue_full.html")]
|
||||
struct IssueFullTemplate {
|
||||
page: Page,
|
||||
display_date: String,
|
||||
issue_number: i64,
|
||||
stats_line: String,
|
||||
front_page_html: String,
|
||||
downloads: Vec<Download>,
|
||||
sections: Vec<FullSection>,
|
||||
has_world: bool,
|
||||
has_behind: bool,
|
||||
date: Date,
|
||||
colophon: ColophonView,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ArticleLink {
|
||||
title: String,
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "article.html")]
|
||||
struct ArticleTemplate {
|
||||
page: Page,
|
||||
title: String,
|
||||
source_url: String,
|
||||
byline: Option<String>,
|
||||
meta_line: String,
|
||||
why: Option<String>,
|
||||
social_line: Option<String>,
|
||||
summary: Option<String>,
|
||||
excerpt_only: bool,
|
||||
body_html: String,
|
||||
discussion_html: Option<String>,
|
||||
read_online_url: String,
|
||||
rating: Option<RatingWidget>,
|
||||
previous: Option<ArticleLink>,
|
||||
next: Option<ArticleLink>,
|
||||
issue_href: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "world.html")]
|
||||
struct WorldTemplate {
|
||||
page: Page,
|
||||
display_date: String,
|
||||
body_html: String,
|
||||
issue_href: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NearMissView {
|
||||
article_id: ArticleId,
|
||||
line: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "behind.html")]
|
||||
struct BehindTemplate {
|
||||
page: Page,
|
||||
summary_line: String,
|
||||
admitted_line: String,
|
||||
learned_line: String,
|
||||
near_misses: Vec<NearMissView>,
|
||||
models_line: String,
|
||||
issue_href: String,
|
||||
}
|
||||
|
||||
pub async fn render_full(
|
||||
state: &AppState,
|
||||
view: IssueView,
|
||||
viewer: Viewer,
|
||||
session: &Session,
|
||||
) -> Result<Response, WebError> {
|
||||
let date = view.issue.meta.date;
|
||||
let is_admin = viewer.role == crate::web::users::Role::Admin;
|
||||
let current = if is_admin {
|
||||
rate::current_for_issue(state, date).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let issue_href = format!("/issues/{date}");
|
||||
let sections = chapters::section_names(&view.issue)
|
||||
.into_iter()
|
||||
.map(|name| FullSection {
|
||||
entries: view
|
||||
.issue
|
||||
.lineup
|
||||
.section_picks(&name)
|
||||
.into_iter()
|
||||
.map(|pick| FullEntry {
|
||||
title: pick.article.title.clone(),
|
||||
href: article_href(date, pick.article.id),
|
||||
source: pick.article.feed_title.clone(),
|
||||
reading_minutes: pick.article.reading_minutes(),
|
||||
summary: summary_for(&view.issue, pick)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
why: pick.why.clone(),
|
||||
rating: is_admin.then(|| {
|
||||
RatingWidget::for_issue(
|
||||
pick.article.id,
|
||||
date,
|
||||
issue_href.clone(),
|
||||
current.get(&pick.article.id).map(String::as_str),
|
||||
)
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
})
|
||||
.collect();
|
||||
let colophon = colophon_view(&view.issue);
|
||||
let mut page = Page::new(format!("Issue {date}"), Some(viewer), "latest");
|
||||
page.flash = take_flash(session).await?;
|
||||
Ok(Html(IssueFullTemplate {
|
||||
page,
|
||||
display_date: view.issue.meta.display_date.clone(),
|
||||
issue_number: view.issue.meta.issue_number,
|
||||
stats_line: view.issue.meta.stats_line(),
|
||||
front_page_html: view.issue.editorial.front_page_html.clone(),
|
||||
downloads: view.downloads,
|
||||
sections,
|
||||
has_world: view.issue.world_briefing.is_some(),
|
||||
has_behind: view.from_json,
|
||||
date,
|
||||
colophon,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn article(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Path((date, article_id)): Path<(Date, ArticleId)>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth
|
||||
.user()
|
||||
.await
|
||||
.map(Viewer::from)
|
||||
.ok_or_else(|| WebError::Unauthenticated {
|
||||
next: article_href(date, article_id),
|
||||
})?;
|
||||
let Some(view) = load(&state.db, &state.config(), date).await? else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let Some(index) = view
|
||||
.issue
|
||||
.lineup
|
||||
.picks
|
||||
.iter()
|
||||
.position(|pick| pick.article.id == article_id)
|
||||
else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let pick = &view.issue.lineup.picks[index];
|
||||
let current = if viewer.role == crate::web::users::Role::Admin {
|
||||
rate::current_for_issue(&state, date).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let previous = index.checked_sub(1).map(|previous| {
|
||||
let article = &view.issue.lineup.picks[previous].article;
|
||||
ArticleLink {
|
||||
title: article.title.clone(),
|
||||
href: article_href(date, article.id),
|
||||
}
|
||||
});
|
||||
let next = view
|
||||
.issue
|
||||
.lineup
|
||||
.picks
|
||||
.get(index + 1)
|
||||
.map(|next| ArticleLink {
|
||||
title: next.article.title.clone(),
|
||||
href: article_href(date, next.article.id),
|
||||
});
|
||||
let article = &pick.article;
|
||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), "latest");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(ArticleTemplate {
|
||||
page,
|
||||
title: article.title.clone(),
|
||||
source_url: article.canonical_url.clone(),
|
||||
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
||||
meta_line: format!(
|
||||
"{} · {} words · ~{} min read",
|
||||
article.feed_title,
|
||||
thousands(article.word_count),
|
||||
article.reading_minutes()
|
||||
),
|
||||
why: pick.why.clone(),
|
||||
social_line: chapters::social_line(&article.social),
|
||||
summary: summary_for(&view.issue, pick).map(str::to_string),
|
||||
excerpt_only: article.excerpt_only,
|
||||
body_html: prepare_body(&article.content_html),
|
||||
discussion_html: pick
|
||||
.discussion
|
||||
.as_ref()
|
||||
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
|
||||
read_online_url: article.url.clone(),
|
||||
rating: (viewer.role == crate::web::users::Role::Admin).then(|| {
|
||||
RatingWidget::for_issue(
|
||||
article.id,
|
||||
date,
|
||||
article_href(date, article.id),
|
||||
current.get(&article.id).map(String::as_str),
|
||||
)
|
||||
}),
|
||||
previous,
|
||||
next,
|
||||
issue_href: format!("/issues/{date}"),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn world(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(date): Path<Date>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth
|
||||
.user()
|
||||
.await
|
||||
.map(Viewer::from)
|
||||
.ok_or_else(|| WebError::Unauthenticated {
|
||||
next: format!("/issues/{date}/world"),
|
||||
})?;
|
||||
let Some(view) = load(&state.db, &state.config(), date).await? else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let Some(briefing) = view.issue.world_briefing else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let mut page = Page::new("World Briefing", Some(viewer), "latest");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(WorldTemplate {
|
||||
page,
|
||||
display_date: display_date(briefing.date),
|
||||
body_html: crate::world::render_xhtml(&briefing),
|
||||
issue_href: format!("/issues/{date}"),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn behind(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(date): Path<Date>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth
|
||||
.user()
|
||||
.await
|
||||
.map(Viewer::from)
|
||||
.ok_or_else(|| WebError::Unauthenticated {
|
||||
next: format!("/issues/{date}/behind"),
|
||||
})?;
|
||||
let Some(view) = load(&state.db, &state.config(), date).await? else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
if !view.from_json {
|
||||
return Err(WebError::NotFound);
|
||||
}
|
||||
let behind = &view.issue.behind;
|
||||
let mut page = Page::new("Behind the paper", Some(viewer), "latest");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(BehindTemplate {
|
||||
page,
|
||||
summary_line: chapters::behind_summary_line(behind),
|
||||
admitted_line: chapters::behind_admitted_line(behind),
|
||||
learned_line: chapters::behind_learned_line(behind),
|
||||
near_misses: behind
|
||||
.near_misses
|
||||
.iter()
|
||||
.map(|near_miss| NearMissView {
|
||||
article_id: near_miss.article_id,
|
||||
line: chapters::behind_near_miss_line(near_miss),
|
||||
})
|
||||
.collect(),
|
||||
models_line: chapters::behind_models_line(behind),
|
||||
issue_href: format!("/issues/{date}"),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn article_href(date: Date, article_id: ArticleId) -> String {
|
||||
format!("/issues/{date}/articles/{article_id}")
|
||||
}
|
||||
|
||||
fn summary_for<'a>(issue: &'a Issue, pick: &'a Pick) -> Option<&'a str> {
|
||||
pick.summary
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
issue
|
||||
.editorial
|
||||
.summaries
|
||||
.get(&pick.article.id)
|
||||
.map(String::as_str)
|
||||
})
|
||||
.filter(|summary| !summary.trim().is_empty())
|
||||
}
|
||||
|
||||
fn colophon_view(issue: &Issue) -> ColophonView {
|
||||
let colophon = &issue.colophon;
|
||||
ColophonView {
|
||||
generated_at: issue.meta.generated_at.to_string(),
|
||||
bulk_model: colophon.models.bulk.clone(),
|
||||
editor_model: colophon.models.editor.clone(),
|
||||
summaries_model: colophon.models.summaries.clone(),
|
||||
provider_costs: colophon
|
||||
.provider_costs
|
||||
.iter()
|
||||
.map(|(provider, cost)| CostLine {
|
||||
provider: provider.clone(),
|
||||
cost: format!("${cost:.4}"),
|
||||
})
|
||||
.collect(),
|
||||
entries_fetched: colophon.entries_fetched,
|
||||
feeds_seen: colophon.feeds_seen,
|
||||
candidates: colophon.candidates,
|
||||
article_count: issue.meta.article_count,
|
||||
section_count: issue.meta.section_count,
|
||||
total_words: thousands(issue.meta.total_words),
|
||||
reading_minutes: issue.meta.reading_minutes,
|
||||
cost_usd: format!("${:.4}", colophon.cost_usd),
|
||||
generator_version: if colophon.generator_version.is_empty() {
|
||||
format!("daily-epub {}", env!("CARGO_PKG_VERSION"))
|
||||
} else {
|
||||
colophon.generator_version.clone()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn thousands(value: i64) -> String {
|
||||
let digits = value.abs().to_string();
|
||||
let mut formatted = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (index, digit) in digits.chars().enumerate() {
|
||||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
||||
formatted.push(',');
|
||||
}
|
||||
formatted.push(digit);
|
||||
}
|
||||
if value < 0 {
|
||||
format!("-{formatted}")
|
||||
} else {
|
||||
formatted
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize article HTML exactly as the EPUB does, then add browser-only image
|
||||
/// loading/referrer attributes without converting the fragment to XHTML.
|
||||
fn prepare_body(html: &str) -> String {
|
||||
let clean = ammonia::clean(html);
|
||||
let mut output = String::with_capacity(clean.len() + 64);
|
||||
let mut cursor = 0usize;
|
||||
while let Some(relative) = clean[cursor..].find('<') {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&clean[cursor..start]);
|
||||
let Some(end) = crate::html::tag_end(&clean, start) else {
|
||||
output.push_str(&clean[start..]);
|
||||
return output;
|
||||
};
|
||||
let raw = &clean[start..end];
|
||||
let inner = raw.trim_start_matches('<').trim_end_matches('>');
|
||||
if crate::html::tag_name(inner) == "img" {
|
||||
let attributes = crate::html::parse_attrs(inner);
|
||||
let trimmed = raw.trim_end_matches('>');
|
||||
output.push_str(trimmed.trim_end_matches('/'));
|
||||
if !attributes.iter().any(|(name, _)| name == "loading") {
|
||||
output.push_str(" loading=\"lazy\"");
|
||||
}
|
||||
if !attributes.iter().any(|(name, _)| name == "referrerpolicy") {
|
||||
output.push_str(" referrerpolicy=\"no-referrer\"");
|
||||
}
|
||||
output.push('>');
|
||||
} else {
|
||||
output.push_str(raw);
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
output.push_str(&clean[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::types::{Entry, Issue};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("x-forwarded-for", "192.0.2.88")
|
||||
.body(Body::from(format!(
|
||||
"username={username}&password={password}&next=%2F"
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn response_text(response: Response) -> String {
|
||||
String::from_utf8(
|
||||
to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn seeded_issue(with_json: bool) -> (tempfile::TempDir, Db, Issue) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||
@@ -382,4 +891,388 @@ mod tests {
|
||||
String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap();
|
||||
assert!(reports.contains("\"status\": \"ok\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_in_full_issue_article_world_and_behind_render_private_content() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = crate::server::router(crate::server::AppState::new(
|
||||
db,
|
||||
crate::config::Config::default(),
|
||||
None,
|
||||
));
|
||||
let anonymous = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/issues/{}/articles/{}",
|
||||
source.meta.date, source.lineup.picks[0].article.id
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), StatusCode::FOUND);
|
||||
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
|
||||
let issue = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}", source.meta.date))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(issue.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
issue.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"private, no-store"
|
||||
);
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains("The Brief"));
|
||||
assert!(issue.contains("Two stories today"));
|
||||
assert!(issue.contains("What it argues"));
|
||||
assert!(issue.contains("A short abstract for the second piece"));
|
||||
assert!(issue.contains("Why it"));
|
||||
assert!(issue.contains("World Briefing"));
|
||||
assert!(issue.contains("Behind the paper"));
|
||||
assert!(!issue.contains("Was this a good pick?"));
|
||||
|
||||
let article_id = source.lineup.picks[0].article.id;
|
||||
let article = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/issues/{}/articles/{article_id}",
|
||||
source.meta.date
|
||||
))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(article.status(), StatusCode::OK);
|
||||
let article = response_text(article).await;
|
||||
assert!(article.contains("Body of <em>The Lead Story</em>"));
|
||||
assert!(article.contains("The write path is the interesting part"));
|
||||
assert!(article.contains("loading=\"lazy\""));
|
||||
assert!(article.contains("referrerpolicy=\"no-referrer\""));
|
||||
assert!(article.contains("A Niche Delight"));
|
||||
assert!(article.contains("rel=\"next\""));
|
||||
|
||||
let world = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}/world", source.meta.date))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(world.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response_text(world)
|
||||
.await
|
||||
.contains("Something happened somewhere")
|
||||
);
|
||||
|
||||
let behind = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}/behind", source.meta.date))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(behind.status(), StatusCode::OK);
|
||||
let behind = response_text(behind).await;
|
||||
assert!(behind.contains("Considered 412 articles"));
|
||||
assert!(!behind.contains("/dashboard/articles/3"));
|
||||
|
||||
let missing = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}/articles/999999", source.meta.date))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response_text(missing).await.contains("Not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_full_issue_omits_ephemeral_chapter_links() {
|
||||
let (_dir, db, source) = seeded_issue(false).await;
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = crate::server::router(crate::server::AppState::new(
|
||||
db,
|
||||
crate::config::Config::default(),
|
||||
None,
|
||||
));
|
||||
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
let issue = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}", source.meta.date))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(issue.status(), StatusCode::OK);
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains("Two stories today"));
|
||||
assert!(!issue.contains(&format!("/issues/{}/world", source.meta.date)));
|
||||
assert!(!issue.contains(&format!("/issues/{}/behind", source.meta.date)));
|
||||
|
||||
let world = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}/world", source.meta.date))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(world.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_are_listed_only_while_the_files_exist() {
|
||||
let (dir, db, source) = seeded_issue(true).await;
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let epub_dir = dir.path().join("epubs");
|
||||
std::fs::create_dir(&epub_dir).unwrap();
|
||||
let standard = epub_dir.join(crate::publish::issue_filename(
|
||||
source.meta.date,
|
||||
Edition::Standard,
|
||||
"epub",
|
||||
));
|
||||
std::fs::write(&standard, b"epub").unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.publish.epub_dir = epub_dir;
|
||||
let app = crate::server::router(crate::server::AppState::new(db, config, None));
|
||||
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
let issue = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}", source.meta.date))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains("Download EPUB"));
|
||||
assert!(!issue.contains("Download X4 EPUB"));
|
||||
assert!(!issue.contains("Download XTC"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rating_post_supports_json_forms_attribution_fallback_and_clear() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
let admin = crate::web::users::add(&db, "admin", "correct horse battery", true)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = crate::server::router(crate::server::AppState::new(
|
||||
db.clone(),
|
||||
crate::config::Config::default(),
|
||||
None,
|
||||
));
|
||||
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
let article_id = source.lineup.picks[0].article.id;
|
||||
|
||||
let json_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/rate")
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(
|
||||
json!({"article_id": article_id, "label": "loved"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(json_response.status(), StatusCode::OK);
|
||||
let json_body: serde_json::Value =
|
||||
serde_json::from_str(&response_text(json_response).await).unwrap();
|
||||
assert_eq!(json_body["article_id"], article_id);
|
||||
assert_eq!(json_body["label"], "loved");
|
||||
assert!(json_body["event_id"].as_i64().is_some());
|
||||
let stored = sqlx::query(
|
||||
"SELECT source, user_id, issue_date, label, value FROM rating_events ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.get::<String, _>("source"), "dashboard");
|
||||
assert_eq!(stored.get::<Option<i64>, _>("user_id"), Some(admin.id));
|
||||
assert_eq!(
|
||||
stored.get::<Option<String>, _>("issue_date").as_deref(),
|
||||
Some(source.meta.date.to_string().as_str())
|
||||
);
|
||||
|
||||
let admin_issue = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}", source.meta.date))
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let admin_issue = response_text(admin_issue).await;
|
||||
assert!(admin_issue.contains("Was this a good pick?"));
|
||||
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
|
||||
|
||||
let admin_behind = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}/behind", source.meta.date))
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
response_text(admin_behind)
|
||||
.await
|
||||
.contains("/dashboard/articles/3")
|
||||
);
|
||||
|
||||
let clear = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/rate")
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(format!(
|
||||
"article_id={article_id}&label=cleared&next=https%3A%2F%2Fevil.example"
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(clear.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(clear.headers().get(header::LOCATION).unwrap(), "/");
|
||||
let cleared =
|
||||
sqlx::query("SELECT label, value FROM rating_events ORDER BY id DESC LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cleared.get::<String, _>("label"), "cleared");
|
||||
assert_eq!(cleared.get::<f64, _>("value"), 0.0);
|
||||
|
||||
let form = format!(
|
||||
"article_id={article_id}&issue_date={}&label=down&next=%2Fissues%2F{}",
|
||||
source.meta.date, source.meta.date
|
||||
);
|
||||
let forbidden = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/rate")
|
||||
.header(header::COOKIE, reader_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(form.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
|
||||
let anonymous = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/rate")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(form.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), StatusCode::FOUND);
|
||||
|
||||
let valid = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/rate")
|
||||
.header(header::COOKIE, admin_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(form))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(valid.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
valid.headers().get(header::LOCATION).unwrap(),
|
||||
format!("/issues/{}", source.meta.date).as_str()
|
||||
);
|
||||
let down: String =
|
||||
sqlx::query_scalar("SELECT label FROM rating_events ORDER BY id DESC LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(down, "not_for_me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_body_is_sanitized_and_images_get_browser_attributes() {
|
||||
let body = prepare_body(
|
||||
r#"<script>alert(1)</script><img src="https://img.example/a.png" alt="chart"><p>safe</p>"#,
|
||||
);
|
||||
assert!(!body.contains("<script"));
|
||||
assert!(body.contains("src=\"https://img.example/a.png\""));
|
||||
assert!(body.contains("loading=\"lazy\""));
|
||||
assert!(body.contains("referrerpolicy=\"no-referrer\""));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -1,5 +1,6 @@
|
||||
pub mod issue;
|
||||
pub mod public;
|
||||
pub mod rate;
|
||||
pub mod session;
|
||||
pub mod users;
|
||||
|
||||
@@ -18,6 +19,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use self::session::Viewer;
|
||||
use axum_login::tower_sessions::Session;
|
||||
|
||||
#[async_trait]
|
||||
pub trait JobRunner: Send + Sync {
|
||||
@@ -135,6 +137,13 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
|
||||
session
|
||||
.remove("flash")
|
||||
.await
|
||||
.map_err(|error| WebError::Internal(error.into()))
|
||||
}
|
||||
|
||||
pub struct Html<T: Template>(pub T);
|
||||
|
||||
impl<T: Template> IntoResponse for Html<T> {
|
||||
@@ -347,9 +356,19 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
||||
login_url = "/login",
|
||||
redirect_field = "next"
|
||||
));
|
||||
let full_issues = axum::Router::new()
|
||||
.route("/issues/{date}/articles/{article_id}", get(issue::article))
|
||||
.route("/issues/{date}/world", get(issue::world))
|
||||
.route("/issues/{date}/behind", get(issue::behind))
|
||||
.route_layer(login_required!(
|
||||
session::Backend,
|
||||
login_url = "/login",
|
||||
redirect_field = "next"
|
||||
))
|
||||
.route_layer(from_fn(map_forbidden));
|
||||
let dashboard = axum::Router::new()
|
||||
.route("/dashboard", get(dashboard_stub))
|
||||
.route("/rate", post(rate_stub))
|
||||
.route("/rate", post(rate::post))
|
||||
.route_layer(permission_required!(
|
||||
session::Backend,
|
||||
login_url = "/login",
|
||||
@@ -367,6 +386,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
||||
.route("/static/{file}", get(static_asset))
|
||||
.merge(login)
|
||||
.merge(account)
|
||||
.merge(full_issues)
|
||||
.merge(dashboard)
|
||||
}
|
||||
|
||||
@@ -384,10 +404,6 @@ async fn dashboard_stub(auth: session::AuthSession) -> Result<Response, WebError
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn rate_stub() -> StatusCode {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
async fn map_forbidden(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
if response.status() == StatusCode::FORBIDDEN {
|
||||
@@ -674,7 +690,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed_rate.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
assert_eq!(allowed_rate.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+11
-9
@@ -1,7 +1,8 @@
|
||||
use askama::Template;
|
||||
use axum::extract::{Path, State};
|
||||
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;
|
||||
@@ -168,6 +169,7 @@ struct FeedEntryTemplate<'a> {
|
||||
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 {
|
||||
@@ -181,12 +183,13 @@ pub async fn latest(
|
||||
.into_response();
|
||||
return Ok(public_cache(response, &headers));
|
||||
};
|
||||
show_issue(State(state), auth, headers, Path(date)).await
|
||||
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> {
|
||||
@@ -194,15 +197,14 @@ pub async fn show_issue(
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
let downloads = if viewer.is_some() {
|
||||
view.downloads
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(viewer) = viewer {
|
||||
let response = issue::render_full(&state, view, viewer, &session).await?;
|
||||
return Ok(public_cache(response, &headers));
|
||||
}
|
||||
let response = Html(IssuePublicTemplate {
|
||||
page: Page::new(format!("Issue {date}"), viewer, "latest"),
|
||||
page: Page::new(format!("Issue {date}"), None, "latest"),
|
||||
issue: PublicIssue::from(&view.issue),
|
||||
downloads,
|
||||
downloads: Vec::new(),
|
||||
empty: false,
|
||||
})
|
||||
.into_response();
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::Form;
|
||||
use axum::extract::{Extension, FromRequest, Json, Request, State};
|
||||
use axum::http::{HeaderMap, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum_login::tower_sessions::Session;
|
||||
use jiff::civil::Date;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::types::{ArticleId, RatingEvent, Vote};
|
||||
use crate::web::session::{self, AuthSession};
|
||||
use crate::web::{Flash, WebError};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RatingWidget {
|
||||
pub article_id: ArticleId,
|
||||
pub issue_date: String,
|
||||
pub next: String,
|
||||
pub current: String,
|
||||
pub show_note: bool,
|
||||
}
|
||||
|
||||
impl RatingWidget {
|
||||
pub fn for_issue(
|
||||
article_id: ArticleId,
|
||||
issue_date: Date,
|
||||
next: impl Into<String>,
|
||||
current: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
article_id,
|
||||
issue_date: issue_date.to_string(),
|
||||
next: next.into(),
|
||||
current: web_label(current).to_string(),
|
||||
show_note: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn web_label(label: Option<&str>) -> &str {
|
||||
match label {
|
||||
Some("not_for_me" | "down") => "down",
|
||||
Some("loved") => "loved",
|
||||
Some("good") => "good",
|
||||
Some("cleared") => "cleared",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn current_for_issue(
|
||||
state: &AppState,
|
||||
date: Date,
|
||||
) -> Result<HashMap<ArticleId, String>, WebError> {
|
||||
let rows = sqlx::query(
|
||||
"WITH ranked AS (
|
||||
SELECT re.article_id, re.label,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY re.article_id
|
||||
ORDER BY re.event_at DESC, re.id DESC
|
||||
) AS event_rank
|
||||
FROM rating_events re
|
||||
JOIN issue_articles ia ON ia.article_id = re.article_id
|
||||
WHERE re.kind = 'explicit' AND ia.issue_date = ?
|
||||
)
|
||||
SELECT article_id, label FROM ranked WHERE event_rank = 1",
|
||||
)
|
||||
.bind(date.to_string())
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(crate::db::DbError::from)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| (row.get("article_id"), row.get("label")))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RatingInput {
|
||||
article_id: ArticleId,
|
||||
#[serde(default)]
|
||||
issue_date: Option<String>,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
note: Option<String>,
|
||||
#[serde(default)]
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RatingResponse {
|
||||
article_id: ArticleId,
|
||||
label: String,
|
||||
event_id: i64,
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
) -> Result<Response, WebError> {
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let input = if content_type.starts_with("application/json") {
|
||||
Json::<RatingInput>::from_request(request, &state)
|
||||
.await
|
||||
.map(|Json(input)| input)
|
||||
.map_err(|_| WebError::BadRequest("invalid rating JSON".into()))?
|
||||
} else {
|
||||
Form::<RatingInput>::from_request(request, &state)
|
||||
.await
|
||||
.map(|Form(input)| input)
|
||||
.map_err(|_| WebError::BadRequest("invalid rating form".into()))?
|
||||
};
|
||||
|
||||
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||
next: "/rate".into(),
|
||||
})?;
|
||||
if state.db.get_article(input.article_id).await?.is_none() {
|
||||
return Err(WebError::BadRequest(format!(
|
||||
"article {} does not exist",
|
||||
input.article_id
|
||||
)));
|
||||
}
|
||||
|
||||
let issue_date = match input
|
||||
.issue_date
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(raw) => Some(
|
||||
raw.parse::<Date>()
|
||||
.map_err(|_| WebError::BadRequest("invalid issue date".into()))?,
|
||||
),
|
||||
None => {
|
||||
state
|
||||
.db
|
||||
.latest_issue_date_for_article(input.article_id)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let config = state.config();
|
||||
let (event_label, value, response_label, flash_label) = match input.label.as_str() {
|
||||
"loved" => (
|
||||
"loved",
|
||||
Vote::Loved.value(&config.curation.feedback),
|
||||
"loved",
|
||||
"Loved it",
|
||||
),
|
||||
"good" => (
|
||||
"good",
|
||||
Vote::Good.value(&config.curation.feedback),
|
||||
"good",
|
||||
"Good",
|
||||
),
|
||||
"down" => (
|
||||
"not_for_me",
|
||||
Vote::NotForMe.value(&config.curation.feedback),
|
||||
"down",
|
||||
"Not for me",
|
||||
),
|
||||
"cleared" => ("cleared", 0.0, "cleared", "Cleared"),
|
||||
_ => return Err(WebError::BadRequest("invalid rating label".into())),
|
||||
};
|
||||
let note = input
|
||||
.note
|
||||
.map(|note| note.trim().to_string())
|
||||
.filter(|note| !note.is_empty());
|
||||
let event_id = state
|
||||
.db
|
||||
.append_rating_event(&RatingEvent {
|
||||
id: 0,
|
||||
user_id: Some(viewer.id),
|
||||
article_id: input.article_id,
|
||||
issue_date,
|
||||
kind: "explicit".into(),
|
||||
source: "dashboard".into(),
|
||||
label: event_label.into(),
|
||||
value,
|
||||
note,
|
||||
event_at: jiff::Timestamp::now(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let wants_json = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("application/json"));
|
||||
if wants_json {
|
||||
return Ok(Json(RatingResponse {
|
||||
article_id: input.article_id,
|
||||
label: response_label.into(),
|
||||
event_id,
|
||||
})
|
||||
.into_response());
|
||||
}
|
||||
|
||||
session
|
||||
.insert(
|
||||
"flash",
|
||||
Flash {
|
||||
kind: "success".into(),
|
||||
text: format!("Rated: {flash_label}"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| WebError::Internal(error.into()))?;
|
||||
let destination = session::valid_next(input.next.as_deref()).to_string();
|
||||
Ok(axum::response::Redirect::to(&destination).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn database_and_widget_labels_are_mapped_explicitly() {
|
||||
assert_eq!(web_label(Some("not_for_me")), "down");
|
||||
assert_eq!(web_label(Some("cleared")), "cleared");
|
||||
assert_eq!(web_label(None), "");
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -11,11 +11,29 @@ a { color:var(--accent); }
|
||||
main { min-height:70vh; }
|
||||
.reading { max-width:72ch; margin:2rem auto; }
|
||||
.narrow { max-width:34rem; }
|
||||
.dateline,.stats,.byline,.comments,.strap { color:var(--muted); }
|
||||
.dateline,.stats,.byline,.comments,.strap,.meta,.social,.index-meta { color:var(--muted); }
|
||||
.issue section { border-top:1px solid var(--rule); margin-top:2rem; }
|
||||
.issue article { border-bottom:1px solid var(--rule); padding:.4rem 0 .8rem; }
|
||||
.issue .lead h3 { font-size:1.45rem; }
|
||||
.comments a,.downloads a { margin-right:.8rem; }
|
||||
.downloads { display:flex; flex-wrap:wrap; gap:.6rem; margin:1.5rem 0; }
|
||||
.button { border:1px solid currentColor; padding:.45rem .7rem; text-decoration:none; }
|
||||
.index-list { list-style:none; padding:0; }
|
||||
.index-entry { border-bottom:1px solid var(--rule); padding:.5rem 0 1rem; }
|
||||
.index-entry h3 { margin-bottom:.1rem; }
|
||||
.index-entry p { margin:.35rem 0; }
|
||||
.issue-chapters { border-block:1px solid var(--rule); margin:2rem 0; padding:1rem 0; text-align:center; font-size:1.15rem; }
|
||||
.colophon { max-width:none; margin:2rem 0; text-align:left; }
|
||||
.rule { border:0; border-top:1px solid var(--rule); margin:1.5rem 0; }
|
||||
.article-header h1 a { color:inherit; }
|
||||
.article-body img { display:block; max-width:100%; height:auto; margin:1rem auto; }
|
||||
.article-body pre { max-width:100%; overflow-x:auto; }
|
||||
.discussion { border-top:1px solid var(--rule); margin-top:2rem; padding-top:1rem; }
|
||||
.discussion blockquote { border-left:2px solid var(--rule); margin-left:.5rem; padding-left:1rem; }
|
||||
.discussion blockquote.reply { margin-left:1.5rem; }
|
||||
.comment-meta { color:var(--muted); font-size:.9rem; }
|
||||
.prev-next { display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin:1.5rem 0; }
|
||||
.prev-next a:last-child { text-align:right; }
|
||||
form { display:grid; gap:.8rem; margin:1.5rem 0; }
|
||||
label { display:grid; gap:.25rem; }
|
||||
input,textarea,button { font:inherit; padding:.45rem; }
|
||||
@@ -31,5 +49,11 @@ thead { position:sticky; top:0; background:var(--bg); }
|
||||
.kv { display:grid; grid-template-columns:minmax(10rem,1fr) 3fr; } .kv dt { color:var(--muted); }
|
||||
.funnel > * { background:var(--good); min-width:1px; margin:.2rem 0; }
|
||||
.spark { max-width:100%; height:auto; }
|
||||
.rating { display:flex; flex-wrap:wrap; gap:.3rem; }
|
||||
.rating { display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; }
|
||||
.rating button { border:1px solid var(--rule); background:transparent; color:var(--fg); cursor:pointer; }
|
||||
.rating button.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
|
||||
.rating button.clear { border:0; color:var(--muted); padding-inline:.25rem; text-decoration:underline; }
|
||||
.rating button.clear.active { background:transparent; color:var(--fg); font-weight:700; }
|
||||
.rating-prompt { margin-right:.25rem; }
|
||||
.rating-note { flex-basis:100%; }
|
||||
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
|
||||
|
||||
+31
-1
@@ -1,5 +1,35 @@
|
||||
document.addEventListener("submit", (event) => {
|
||||
const message = event.target.dataset.confirm;
|
||||
const form = event.target;
|
||||
if (form.matches("form.rating") && event.submitter) {
|
||||
event.preventDefault();
|
||||
const submitted = event.submitter;
|
||||
const body = new URLSearchParams(new FormData(form));
|
||||
body.set(submitted.name, submitted.value);
|
||||
fetch(form.action, {
|
||||
method: "POST",
|
||||
body,
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
}).then((response) => {
|
||||
if (!response.ok) throw new Error("rating request failed");
|
||||
return response.json();
|
||||
}).then((result) => {
|
||||
form.querySelectorAll("button[data-label]").forEach((button) => {
|
||||
const active = button.dataset.label === result.label;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}).catch(() => {
|
||||
const label = document.createElement("input");
|
||||
label.type = "hidden";
|
||||
label.name = submitted.name;
|
||||
label.value = submitted.value;
|
||||
form.appendChild(label);
|
||||
form.submit();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = form.dataset.confirm;
|
||||
if (message && !window.confirm(message)) event.preventDefault();
|
||||
});
|
||||
document.querySelectorAll("details[id]").forEach((details) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<form class="rating" method="post" action="/rate">
|
||||
<input type="hidden" name="article_id" value="{{ widget.article_id }}">
|
||||
<input type="hidden" name="issue_date" value="{{ widget.issue_date }}">
|
||||
<input type="hidden" name="next" value="{{ widget.next }}">
|
||||
<span class="rating-prompt">Was this a good pick?</span>
|
||||
<button type="submit" name="label" value="loved" data-label="loved"{% if widget.current == "loved" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Loved it</button>
|
||||
<button type="submit" name="label" value="good" data-label="good"{% if widget.current == "good" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Good</button>
|
||||
<button type="submit" name="label" value="down" data-label="down"{% if widget.current == "down" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Not for me</button>
|
||||
{% if widget.show_note %}<label class="rating-note">Note <input name="note"></label>{% endif %}
|
||||
<button class="clear{% if widget.current == "cleared" %} active{% endif %}" type="submit" name="label" value="cleared" data-label="cleared"{% if widget.current == "cleared" %} aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>clear</button>
|
||||
</form>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "layout.html" %}{% block content %}<article class="reading article-page">
|
||||
<header class="article-header">
|
||||
<h1><a href="{{ source_url }}">{{ title }}</a></h1>
|
||||
{% match byline %}{% when Some with (byline) %}<p class="byline">{{ byline }}</p>{% when None %}{% endmatch %}
|
||||
<p class="meta">{{ meta_line }}</p>
|
||||
{% match why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
|
||||
{% match social_line %}{% when Some with (social) %}<p class="social">{{ social }}</p>{% when None %}{% endmatch %}
|
||||
{% match summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}
|
||||
{% if excerpt_only %}<p class="notice">(excerpt only — read online)</p>{% endif %}
|
||||
</header>
|
||||
<hr class="rule">
|
||||
<div class="article-body">{{ body_html|safe }}</div>
|
||||
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion"><h2>Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
||||
<footer class="article-footer">
|
||||
{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
|
||||
<p><a href="{{ read_online_url }}">Read online ↗</a></p>
|
||||
<nav class="prev-next">{% match previous %}{% when Some with (previous) %}<a rel="prev" href="{{ previous.href }}">← {{ previous.title }}</a>{% when None %}<span></span>{% endmatch %}{% match next %}{% when Some with (next) %}<a rel="next" href="{{ next.href }}">{{ next.title }} →</a>{% when None %}{% endmatch %}</nav>
|
||||
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
||||
</footer>
|
||||
</article>{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "layout.html" %}{% block content %}<article class="reading behind">
|
||||
<h1>Behind the paper</h1>
|
||||
<p class="fact-line">{{ summary_line }}</p><p class="fact-line">{{ admitted_line }}</p><p class="fact-line">{{ learned_line }}</p>
|
||||
<h2>Near misses</h2><p><em>Highest utility not selected.</em></p>
|
||||
{% if near_misses.is_empty() %}<p>None recorded for this run.</p>{% else %}<ul class="near-misses">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ul>{% endif %}
|
||||
<p class="fact-line">{{ models_line }}</p><p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
||||
</article>{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "layout.html" %}{% block content %}<article class="reading issue full-issue">
|
||||
<p class="dateline">{{ display_date }} · No. {{ issue_number }}</p>
|
||||
<p class="stats">{{ stats_line }}</p>
|
||||
<hr class="rule">
|
||||
<h1 class="kicker">The Brief</h1>
|
||||
<div class="editorial">{{ front_page_html|safe }}</div>
|
||||
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size_bytes }} bytes)</small></a>{% endfor %}</p>{% endif %}
|
||||
<hr class="rule">
|
||||
<h1>In This Issue</h1>
|
||||
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
|
||||
<h3><a href="{{ entry.href }}">{{ entry.title }}</a></h3>
|
||||
<p class="index-meta">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>
|
||||
{% if !entry.summary.is_empty() %}<p class="index-summary">{{ entry.summary }}</p>{% endif %}
|
||||
{% match entry.why %}{% when Some with (why) %}<p class="index-why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
|
||||
{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
|
||||
</li>{% endfor %}</ul></section>{% endfor %}
|
||||
{% if has_world || has_behind %}<nav class="issue-chapters">{% if has_world %}<a href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %} · {% endif %}{% if has_behind %}<a href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
||||
<footer class="colophon"><h2>Colophon</h2>
|
||||
<p><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p>
|
||||
<dl class="kv">
|
||||
<dt>Generated</dt><dd>{{ colophon.generated_at }}</dd>
|
||||
<dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd>
|
||||
<dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd>
|
||||
<dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd>
|
||||
<dt>Entries considered</dt><dd>{{ colophon.entries_fetched }} from {{ colophon.feeds_seen }} feeds</dd>
|
||||
<dt>Candidates scored</dt><dd>{{ colophon.candidates }}</dd>
|
||||
<dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd>
|
||||
<dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>
|
||||
{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}
|
||||
<dt>Total token cost</dt><dd>{{ colophon.cost_usd }}</dd>
|
||||
<dt>Generator</dt><dd>{{ colophon.generator_version }}</dd>
|
||||
</dl></footer>
|
||||
</article>{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "layout.html" %}{% block content %}<article class="reading world-briefing">
|
||||
<h1>World Briefing</h1><p class="dateline">{{ display_date }}</p><hr class="rule">
|
||||
<div class="world-body">{{ body_html|safe }}</div>
|
||||
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
|
||||
</article>{% endblock %}
|
||||
Reference in New Issue
Block a user