Web dashboard step 1: migration, sessions, users CLI, public site

Migration 0004 (users, sessions, config_changes, profile_versions, jobs,
rating_events.user_id, runs.report_json, issues.issue_json), the issue
snapshot writer and loader, the web module skeleton with layout and static
assets, axum-login/tower-sessions over a sqlx session store, password-auth
users with a CLI, the login throttle, the origin check, security headers,
and the public issue pages, archive, Atom feed and robots.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 04:38:22 +00:00
co-authored by Claude Fable 5.1
parent a70de4af06
commit 69cd736455
35 changed files with 3801 additions and 69 deletions
+385
View File
@@ -0,0 +1,385 @@
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use anyhow::Context;
use jiff::civil::Date;
use sqlx::Row;
use crate::db::Db;
use crate::pipeline::display_date;
use crate::types::{BehindThePaper, Colophon, Editorial, Issue, IssueMeta, Lineup, Pick};
#[derive(Debug, Clone)]
pub struct Download {
pub label: String,
pub href: String,
pub size_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct IssueView {
pub issue: Issue,
pub downloads: Vec<Download>,
pub from_json: bool,
}
pub async fn load(
db: &Db,
config: &crate::config::Config,
date: Date,
) -> anyhow::Result<Option<IssueView>> {
let Some(row) = db.issue_by_date(date).await? else {
return Ok(None);
};
let (mut issue, from_json) = if let Some(raw) = row.issue_json.as_deref() {
let mut issue: Issue = serde_json::from_str(raw).context("decoding issues.issue_json")?;
for pick in &mut issue.lineup.picks {
if let Some(article) = db.get_article(pick.article.id).await? {
pick.article = article;
}
}
(issue, true)
} else {
let rows = sqlx::query(
"SELECT article_id, section, position, is_lead, summary, why
FROM issue_articles WHERE issue_date = ? ORDER BY section, position",
)
.bind(date.to_string())
.fetch_all(db.pool())
.await?;
let mut picks = Vec::with_capacity(rows.len());
let mut seen_sections = Vec::new();
let mut summaries = BTreeMap::new();
for pick_row in rows {
let article_id: i64 = pick_row.get("article_id");
let Some(article) = db.get_article(article_id).await? else {
continue;
};
let section: String = pick_row.get("section");
if !seen_sections.contains(&section) {
seen_sections.push(section.clone());
}
let summary: Option<String> = pick_row.get("summary");
if let Some(summary) = &summary {
summaries.insert(article_id, summary.clone());
}
picks.push(Pick {
article,
section,
position: pick_row.get("position"),
is_lead: pick_row.get("is_lead"),
why: pick_row.get("why"),
summary,
llm: None,
discussion: None,
});
}
let configured: HashSet<&str> = config
.curation
.sections
.iter()
.map(String::as_str)
.collect();
let mut section_order: Vec<String> = config
.curation
.sections
.iter()
.filter(|section| seen_sections.contains(section))
.cloned()
.collect();
section_order.extend(
seen_sections
.into_iter()
.filter(|section| !configured.contains(section.as_str())),
);
picks.sort_by_key(|pick| {
let section = section_order
.iter()
.position(|value| value == &pick.section)
.unwrap_or(usize::MAX);
(section, pick.position)
});
let total_words = picks.iter().map(|pick| pick.article.word_count).sum();
let article_count = picks.len() as i64;
let section_count = section_order.len() as i64;
(
Issue {
meta: IssueMeta {
date,
issue_number: row.issue_number,
generated_at: row.generated_at,
display_date: display_date(date),
article_count,
section_count,
total_words,
reading_minutes: crate::types::reading_minutes(total_words),
},
lineup: Lineup {
date,
picks,
section_order,
},
editorial: Editorial {
front_page_html: row.front_page_html.unwrap_or_default(),
summaries,
},
world_briefing: None,
colophon: Colophon::default(),
behind: BehindThePaper::default(),
},
false,
)
};
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"),
]
.into_iter()
.filter_map(|(label, raw, kind)| download(label, raw?, kind))
.collect();
Ok(Some(IssueView {
issue,
downloads,
from_json,
}))
}
fn download(label: &str, raw: &str, kind: &str) -> Option<Download> {
let path = Path::new(raw);
let metadata = path.metadata().ok()?;
let name = path.file_name()?.to_str()?;
Some(Download {
label: label.to_string(),
href: format!("/files/{kind}/{}", crate::web::encode_component(name)),
size_bytes: metadata.len(),
})
}
#[cfg(test)]
mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use tower::ServiceExt;
use crate::types::{Entry, Issue};
use super::*;
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"))
.await
.unwrap();
let mut issue = crate::epub::fixtures::issue();
for pick in &mut issue.lineup.picks {
let article = &pick.article;
db.upsert_entry(&Entry {
id: article.best_entry_id,
feed_id: article.feed_id,
feed_title: Some(article.feed_title.clone()),
category: article.category.clone(),
title: article.title.clone(),
url: article.url.clone(),
canonical_url: Some(article.canonical_url.clone()),
author: article.author.clone(),
published_at: article.published_at,
comments_url: article.comments_url.clone(),
raw_content: article.content_html.clone(),
fetched_at: article.first_seen,
})
.await
.unwrap();
let id = db.upsert_article(article).await.unwrap();
pick.article.id = id;
for social in &mut pick.article.social {
social.article_id = id;
db.upsert_social(social).await.unwrap();
}
}
let issue_json = with_json.then(|| {
let mut snapshot = issue.clone();
for pick in &mut snapshot.lineup.picks {
pick.article.content_html.clear();
}
serde_json::to_string(&snapshot).unwrap()
});
db.upsert_issue(
issue.meta.date,
issue.meta.issue_number,
issue.meta.generated_at,
None,
None,
None,
Some(&issue.editorial.front_page_html),
Some("{\"status\":\"ok\"}"),
issue_json.as_deref(),
)
.await
.unwrap();
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
.await
.unwrap();
(dir, db, issue)
}
#[tokio::test]
async fn issue_json_loader_rehydrates_bodies_and_keeps_ephemeral_content() {
let (_dir, db, source) = seeded_issue(true).await;
let stored = db.issue_by_date(source.meta.date).await.unwrap().unwrap();
let snapshot: Issue = serde_json::from_str(stored.issue_json.as_deref().unwrap()).unwrap();
assert!(
snapshot
.lineup
.picks
.iter()
.all(|pick| pick.article.content_html.is_empty())
);
let loaded = load(&db, &crate::config::Config::default(), source.meta.date)
.await
.unwrap()
.unwrap();
assert!(loaded.from_json);
assert!(
loaded
.issue
.lineup
.picks
.iter()
.all(|pick| !pick.article.content_html.is_empty())
);
assert!(loaded.issue.world_briefing.is_some());
assert!(loaded.issue.lineup.picks[0].discussion.is_some());
}
#[tokio::test]
async fn fallback_loader_builds_reduced_issue_in_configured_section_order() {
let (_dir, db, source) = seeded_issue(false).await;
let loaded = load(&db, &crate::config::Config::default(), source.meta.date)
.await
.unwrap()
.unwrap();
assert!(!loaded.from_json);
assert_eq!(
loaded.issue.lineup.section_order,
["Top Stories", "Niche Corner"]
);
assert!(loaded.issue.world_briefing.is_none());
assert!(
loaded
.issue
.lineup
.picks
.iter()
.all(|pick| pick.discussion.is_none())
);
assert!(loaded.issue.editorial.front_page_html.contains("coffee"));
}
#[tokio::test]
async fn public_issue_archive_feed_robots_and_reports_are_served() {
let (_dir, db, source) = seeded_issue(true).await;
let app = crate::server::router(crate::server::AppState::new(
db,
crate::config::Config::default(),
None,
));
let issue = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(issue.status(), StatusCode::OK);
assert_eq!(
issue.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let html = String::from_utf8(
to_bytes(issue.into_body(), 1024 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(html.contains("The Lead Story"));
assert!(html.contains("Hacker News"));
assert!(!html.contains("Two stories today"));
assert!(!html.contains("Something happened"));
let archive = app
.clone()
.oneshot(
Request::builder()
.uri("/issues")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(archive.status(), StatusCode::OK);
let feed = app
.clone()
.oneshot(
Request::builder()
.uri("/feed.xml")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
feed.headers().get(header::CONTENT_TYPE).unwrap(),
"application/atom+xml; charset=utf-8"
);
let feed = String::from_utf8(
to_bytes(feed.into_body(), 1024 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap();
let document = roxmltree::Document::parse(&feed).unwrap();
assert_eq!(
document
.descendants()
.filter(|node| node.tag_name().name() == "entry")
.count(),
1
);
assert!(!feed.contains("Something happened"));
let robots = app
.clone()
.oneshot(
Request::builder()
.uri("/robots.txt")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let robots =
String::from_utf8(to_bytes(robots.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(robots.contains("Disallow: /dashboard"));
let reports = app
.oneshot(
Request::builder()
.uri("/issues.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let reports =
String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(reports.contains("\"status\": \"ok\""));
}
}
+914
View File
@@ -0,0 +1,914 @@
pub mod issue;
pub mod public;
pub mod session;
pub mod users;
use std::fmt;
use std::sync::Mutex;
use std::time::SystemTime;
use askama::Template;
use async_trait::async_trait;
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use self::session::Viewer;
#[async_trait]
pub trait JobRunner: Send + Sync {
async fn start(&self, unit: &str) -> Result<(), String>;
async fn status(&self, unit: &str) -> Result<UnitStatus, String>;
async fn log(&self, unit: &str, lines: usize) -> Result<String, String>;
}
#[derive(Debug, Clone, Default)]
pub struct UnitStatus {
pub active_state: String,
pub sub_state: String,
pub result: String,
pub exit_status: Option<i32>,
}
#[derive(Debug, Default)]
pub struct DisabledRunner;
#[async_trait]
impl JobRunner for DisabledRunner {
async fn start(&self, _unit: &str) -> Result<(), String> {
Err("jobs are disabled".into())
}
async fn status(&self, _unit: &str) -> Result<UnitStatus, String> {
Err("jobs are disabled".into())
}
async fn log(&self, _unit: &str, _lines: usize) -> Result<String, String> {
Err("jobs are disabled".into())
}
}
/// In-memory runner for router tests. Step 6 will add scripted results alongside
/// these recorded calls when the jobs pages begin invoking the runner.
#[derive(Debug, Default)]
pub struct MockRunner {
calls: Mutex<Vec<String>>,
}
impl MockRunner {
pub fn calls(&self) -> Vec<String> {
self.calls.lock().expect("mock runner lock").clone()
}
fn record(&self, call: String) {
self.calls.lock().expect("mock runner lock").push(call);
}
}
#[async_trait]
impl JobRunner for MockRunner {
async fn start(&self, unit: &str) -> Result<(), String> {
self.record(format!("start {unit}"));
Ok(())
}
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
self.record(format!("status {unit}"));
Ok(UnitStatus::default())
}
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
self.record(format!("log {unit} {lines}"));
Ok(String::new())
}
}
pub struct WebState {
pub jobs: std::sync::Arc<dyn JobRunner>,
pub started_at: Timestamp,
pub config_mtime: Mutex<Option<SystemTime>>,
}
impl fmt::Debug for WebState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebState")
.field("started_at", &self.started_at)
.field("config_mtime", &self.config_mtime)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flash {
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct Page {
pub title: String,
pub viewer: Option<Viewer>,
pub flash: Option<Flash>,
pub active_nav: String,
pub version: &'static str,
}
impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self {
title: title.into(),
viewer,
flash: None,
active_nav: active_nav.to_string(),
version: crate::VERSION,
}
}
pub fn is_admin(&self) -> bool {
self.viewer
.as_ref()
.is_some_and(|viewer| viewer.role == users::Role::Admin)
}
}
pub struct Html<T: Template>(pub T);
impl<T: Template> IntoResponse for Html<T> {
fn into_response(self) -> Response {
match self.0.render() {
Ok(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response(),
Err(error) => {
tracing::error!(%error, "rendering web template failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum WebError {
#[error("not found")]
NotFound,
#[error("forbidden")]
Forbidden,
#[error("authentication required")]
Unauthenticated { next: String },
#[error("bad request: {0}")]
BadRequest(String),
#[error("request origin did not match this site")]
Csrf,
#[error(transparent)]
Db(#[from] crate::db::DbError),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorTemplate {
page: Page,
heading: String,
message: String,
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
if let Self::Unauthenticated { next } = self {
return axum::response::Redirect::temporary(&format!(
"/login?next={}",
encode_component(&next)
))
.into_response();
}
let (status, heading, message) = match self {
Self::NotFound => (
StatusCode::NOT_FOUND,
"Not found",
"That page does not exist.",
),
Self::Forbidden | Self::Csrf => (
StatusCode::FORBIDDEN,
"Forbidden",
"You do not have permission to do that.",
),
Self::BadRequest(ref message) => {
(StatusCode::BAD_REQUEST, "Bad request", message.as_str())
}
Self::Db(ref error) => {
tracing::error!(%error, "web database request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Server error",
"The request could not be completed.",
)
}
Self::Internal(ref error) => {
tracing::error!(%error, "web request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Server error",
"The request could not be completed.",
)
}
Self::Unauthenticated { .. } => unreachable!(),
};
let rendered = ErrorTemplate {
page: Page::new(heading, None, ""),
heading: heading.to_string(),
message: message.to_string(),
}
.render()
.unwrap_or_else(|_| message.to_string());
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
rendered,
)
.into_response()
}
}
pub fn encode_component(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
#[derive(Debug, Clone, Copy)]
pub struct Pagination {
pub page: u32,
pub per_page: u32,
pub total: i64,
}
impl Pagination {
pub fn offset(self) -> i64 {
i64::from(self.page.saturating_sub(1)) * i64::from(self.per_page)
}
pub fn pages(self) -> u32 {
((self.total.max(0) as u64).div_ceil(u64::from(self.per_page))) as u32
}
}
pub fn format_time(timestamp: Timestamp, config: &crate::config::Config) -> String {
config
.tz()
.map(|tz| {
timestamp
.to_zoned(tz)
.strftime("%Y-%m-%d %H:%M %Z")
.to_string()
})
.unwrap_or_else(|_| timestamp.to_string())
}
pub async fn security_headers(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string();
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
header::HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(
"default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
if path.starts_with("/dashboard") {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
if headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("text/html"))
{
headers.append(header::VARY, HeaderValue::from_static("Cookie"));
}
response
}
pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::AppState> {
use axum::middleware::from_fn;
use axum::routing::{get, post};
use axum_login::{login_required, permission_required};
use tower_governor::GovernorLayer;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
let seconds_per_token = (u64::from(config.server.login_window_minutes) * 60
/ u64::from(config.server.login_attempts))
.max(1);
let governor = std::sync::Arc::new(
GovernorConfigBuilder::default()
.per_second(seconds_per_token)
.burst_size(config.server.login_attempts)
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("validated non-zero login governor configuration"),
);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let cleanup = governor.clone();
handle.spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
cleanup.limiter().retain_recent();
}
});
}
let login = axum::Router::new()
.route("/login", get(session::login_page))
.route(
"/login",
post(session::login).route_layer(GovernorLayer::new(governor)),
);
let account = axum::Router::new()
.route("/account", get(session::account))
.route("/account/password", post(session::change_password))
.route("/account/logout-all", post(session::logout_everywhere))
.route("/logout", post(session::logout))
.route_layer(login_required!(
session::Backend,
login_url = "/login",
redirect_field = "next"
));
let dashboard = axum::Router::new()
.route("/dashboard", get(dashboard_stub))
.route("/rate", post(rate_stub))
.route_layer(permission_required!(
session::Backend,
login_url = "/login",
redirect_field = "next",
users::Role::Admin
))
.route_layer(from_fn(map_forbidden));
axum::Router::new()
.route("/", get(public::latest))
.route("/issues", get(public::archive))
.route("/issues/{date}", get(public::show_issue))
.route("/feed.xml", get(public::feed))
.route("/robots.txt", get(public::robots))
.route("/static/{file}", get(static_asset))
.merge(login)
.merge(account)
.merge(dashboard)
}
#[derive(Template)]
#[template(path = "dashboard/overview.html")]
struct OverviewTemplate {
page: Page,
}
async fn dashboard_stub(auth: session::AuthSession) -> Result<Response, WebError> {
let viewer = auth.user().await.map(session::Viewer::from);
Ok(Html(OverviewTemplate {
page: Page::new("Overview", viewer, "dashboard"),
})
.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 {
WebError::Forbidden.into_response()
} else {
if response.status() == StatusCode::TEMPORARY_REDIRECT {
*response.status_mut() = StatusCode::FOUND;
}
response
}
}
async fn static_asset(
axum::extract::Path(file): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Response {
let asset = match file.as_str() {
"app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")),
"app.js" => (
"application/javascript; charset=utf-8",
include_str!("static/app.js"),
),
"favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")),
_ => return WebError::NotFound.into_response(),
};
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes())));
if headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
== Some(etag.as_str())
{
return (
StatusCode::NOT_MODIFIED,
[
(header::ETAG, etag),
(header::CACHE_CONTROL, "public, max-age=86400".into()),
],
)
.into_response();
}
(
StatusCode::OK,
[
(header::CONTENT_TYPE, asset.0.to_string()),
(header::CACHE_CONTROL, "public, max-age=86400".into()),
(header::ETAG, etag),
],
asset.1,
)
.into_response()
}
#[cfg(test)]
mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, header};
use tower::ServiceExt;
use super::*;
use crate::config::Config;
use crate::db::Db;
use crate::server::{AppState, router};
async fn test_state(config: Config) -> (tempfile::TempDir, AppState) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
(dir, AppState::new(db, config, None))
}
fn post(uri: &str, body: &str, ip: &str) -> Request<Body> {
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin")
.header("x-forwarded-for", ip)
.body(Body::from(body.to_string()))
.unwrap()
}
async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String {
let response = app
.clone()
.oneshot(post(
"/login",
&format!("username={username}&password={password}&next=%2F"),
"192.0.2.1",
))
.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(), 1024 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap()
}
#[tokio::test]
async fn login_cookie_account_logout_and_anonymous_pages() {
let mut config = Config::default();
config.server.public_url = "https://daily.example".into();
let (_dir, state) = test_state(config).await;
users::add(&state.db, "admin", "correct horse battery", true)
.await
.unwrap();
let app = router(state.clone());
let anonymous = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(anonymous.headers().get(header::SET_COOKIE).is_none());
assert_eq!(
anonymous.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let response = app
.clone()
.oneshot(post(
"/login",
"username=admin&password=correct+horse+battery&next=%2Faccount",
"192.0.2.3",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"/account"
);
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.contains("daily_session="));
assert!(set_cookie.contains("HttpOnly"));
assert!(set_cookie.contains("SameSite=Lax"));
assert!(set_cookie.contains("Secure"));
let cookie = set_cookie.split(';').next().unwrap();
let account = app
.clone()
.oneshot(
Request::builder()
.uri("/account")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(account.status(), StatusCode::OK);
assert!(response_text(account).await.contains("admin"));
let logout = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/logout")
.header(header::COOKIE, cookie)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(logout.status(), StatusCode::SEE_OTHER);
}
#[tokio::test]
async fn guards_distinguish_anonymous_users_and_admins() {
let (_dir, state) = test_state(Config::default()).await;
users::add(&state.db, "reader", "correct horse battery", false)
.await
.unwrap();
users::add(&state.db, "admin", "correct horse battery", true)
.await
.unwrap();
let app = router(state);
let anonymous = app
.clone()
.oneshot(
Request::builder()
.uri("/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(anonymous.status(), StatusCode::FOUND);
assert_eq!(
anonymous.headers().get(header::LOCATION).unwrap(),
"/login?next=%2Fdashboard"
);
let anonymous_rate = app
.clone()
.oneshot(post("/rate", "", "192.0.2.20"))
.await
.unwrap();
assert_eq!(anonymous_rate.status(), StatusCode::FOUND);
assert_eq!(
anonymous_rate.headers().get(header::LOCATION).unwrap(),
"/login?next=%2Frate"
);
let reader = login_cookie(&app, "reader", "correct horse battery").await;
let forbidden = app
.clone()
.oneshot(
Request::builder()
.uri("/dashboard")
.header(header::COOKIE, &reader)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
assert!(response_text(forbidden).await.contains("Forbidden"));
let forbidden_rate = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/rate")
.header(header::COOKIE, &reader)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(forbidden_rate.status(), StatusCode::FORBIDDEN);
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let allowed = app
.clone()
.oneshot(
Request::builder()
.uri("/dashboard")
.header(header::COOKIE, &admin)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(allowed.status(), StatusCode::OK);
assert_eq!(
allowed.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
let allowed_rate = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/rate")
.header(header::COOKIE, admin)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(allowed_rate.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn origin_check_rejects_cross_site_and_foreign_origins() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let cross = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/login")
.header("sec-fetch-site", "cross-site")
.header("x-forwarded-for", "192.0.2.10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(cross.status(), StatusCode::FORBIDDEN);
let foreign = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/login")
.header(header::ORIGIN, "https://evil.example")
.header(header::HOST, "daily.hallada.net")
.header("x-forwarded-proto", "https")
.header("x-forwarded-for", "192.0.2.11")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
let same = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/login")
.header(header::ORIGIN, "https://daily.hallada.net")
.header(header::HOST, "daily.hallada.net")
.header("x-forwarded-proto", "https")
.header("x-forwarded-for", "192.0.2.12")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("username=x&password=invalid-invalid"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(same.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_throttle_is_per_ip() {
let mut config = Config::default();
config.server.login_attempts = 3;
let (_dir, state) = test_state(config).await;
let app = router(state);
for _ in 0..3 {
let response = app
.clone()
.oneshot(post(
"/login",
"username=nobody&password=invalid-invalid",
"192.0.2.20",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
let limited = app
.clone()
.oneshot(post(
"/login",
"username=nobody&password=invalid-invalid",
"192.0.2.20",
))
.await
.unwrap();
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
let other = app
.oneshot(post(
"/login",
"username=nobody&password=invalid-invalid",
"192.0.2.21",
))
.await
.unwrap();
assert_eq!(other.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn disabled_users_and_password_changes_invalidate_other_sessions() {
let (_dir, state) = test_state(Config::default()).await;
users::add(&state.db, "reader", "correct horse battery", false)
.await
.unwrap();
let app = router(state.clone());
let first = login_cookie(&app, "reader", "correct horse battery").await;
let second = login_cookie(&app, "reader", "correct horse battery").await;
let changed = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/account/password")
.header(header::COOKIE, &first)
.header("sec-fetch-site", "same-origin")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("current_password=correct+horse+battery&new_password=a+replacement+password&confirm_password=a+replacement+password"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::SEE_OTHER);
let old_session = app
.clone()
.oneshot(
Request::builder()
.uri("/account")
.header(header::COOKIE, second)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(old_session.status().is_redirection());
let fresh = login_cookie(&app, "reader", "a replacement password").await;
users::set_disabled(&state.db, "reader", true)
.await
.unwrap();
let disabled = app
.oneshot(
Request::builder()
.uri("/account")
.header(header::COOKIE, fresh)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(disabled.status().is_redirection());
}
#[tokio::test]
async fn files_accept_a_session_or_basic_auth() {
let mut config = Config::default();
config.server.basic_auth_user = Some("opds".into());
config.server.basic_auth_pass = Some("hunter2".into());
let (dir, state) = test_state(config).await;
let epub_dir = dir.path().join("epub");
std::fs::create_dir_all(&epub_dir).unwrap();
std::fs::write(epub_dir.join("issue.epub"), b"epub").unwrap();
{
let mut live = state.config.write().unwrap();
let mut changed = (**live).clone();
changed.publish.epub_dir = epub_dir;
*live = std::sync::Arc::new(changed);
}
users::add(&state.db, "reader", "correct horse battery", false)
.await
.unwrap();
let app = router(state);
let anonymous = app
.clone()
.oneshot(
Request::builder()
.uri("/files/epub/issue.epub")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
assert!(anonymous.headers().contains_key(header::WWW_AUTHENTICATE));
let basic = app
.clone()
.oneshot(
Request::builder()
.uri("/files/epub/issue.epub")
.header(header::AUTHORIZATION, "Basic b3BkczpodW50ZXIy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(basic.status(), StatusCode::OK);
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let session = app
.oneshot(
Request::builder()
.uri("/files/epub/issue.epub")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(session.status(), StatusCode::OK);
}
#[tokio::test]
async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let first = app
.clone()
.oneshot(
Request::builder()
.uri("/static/app.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(
first.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=86400"
);
let etag = first.headers().get(header::ETAG).unwrap().clone();
let cached = app
.oneshot(
Request::builder()
.uri("/static/app.css")
.header(header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
}
}
+376
View File
@@ -0,0 +1,376 @@
use askama::Template;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
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};
#[derive(Debug, Clone)]
pub struct PublicIssue {
pub date: Date,
pub issue_number: i64,
pub display_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 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,
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(),
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,
}
}
}
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,
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"),
issue: empty_issue(),
downloads: Vec::new(),
empty: true,
})
.into_response();
return Ok(public_cache(response, &headers));
};
show_issue(State(state), auth, headers, Path(date)).await
}
pub async fn show_issue(
State(state): State<AppState>,
auth: AuthSession,
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);
let downloads = if viewer.is_some() {
view.downloads
} else {
Vec::new()
};
let response = Html(IssuePublicTemplate {
page: Page::new(format!("Issue {date}"), viewer, "latest"),
issue: PublicIssue::from(&view.issue),
downloads,
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",
),
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, max-age=300"),
],
body,
)
.into_response())
}
pub async fn robots() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n",
)
.into_response()
}
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, no-store"
} else {
"public, max-age=300"
};
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn empty_issue() -> PublicIssue {
PublicIssue {
date: "1970-01-01".parse().expect("valid epoch date"),
issue_number: 0,
display_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_carries_no_generated_text() {
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"));
for private in [
"Two stories today",
"What it argues",
"systems story",
"Body of",
"write path",
"Agreed",
"Something happened",
"concise view of the day",
] {
assert!(!html.contains(private), "leaked {private:?} in {html}");
}
}
}
+522
View File
@@ -0,0 +1,522 @@
use std::collections::HashSet;
use std::str::FromStr;
use askama::Template;
use async_trait::async_trait;
use axum::Form;
use axum::extract::{Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_login::tower_sessions::session::{Id, Record};
use axum_login::tower_sessions::{SessionStore, session_store};
use axum_login::{AuthUser, AuthnBackend, AuthzBackend};
use serde::Deserialize;
use sqlx::{Row, SqlitePool};
use time::OffsetDateTime;
use crate::db::{Db, fmt_ts};
use crate::server::AppState;
use crate::web::users::{self, Role, User};
use crate::web::{Html, Page, WebError};
const DUMMY_HASH: &str = "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$+r0d29hqEB0yasKr55ZgICsQGSkl0v0kgwhd+U3wyRo";
#[derive(Clone, Debug)]
pub struct SqliteSessionStore {
pool: SqlitePool,
}
impl SqliteSessionStore {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn delete_expired(&self) -> Result<u64, sqlx::Error> {
let result = sqlx::query("DELETE FROM sessions WHERE expiry <= unixepoch()")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete_for_user(&self, user_id: i64) -> Result<u64, sqlx::Error> {
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn store_error(error: impl std::fmt::Display) -> session_store::Error {
session_store::Error::Backend(error.to_string())
}
#[async_trait]
impl SessionStore for SqliteSessionStore {
async fn save(&self, record: &Record) -> session_store::Result<()> {
let data = serde_json::to_string(&record.data).map_err(store_error)?;
let user_id = record
.data
.get("axum-login.data")
.and_then(|value| value.get("user_id"))
.and_then(serde_json::Value::as_i64);
let now = fmt_ts(jiff::Timestamp::now());
sqlx::query(
"INSERT INTO sessions (id, data, expiry, user_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry = excluded.expiry,
user_id = excluded.user_id, updated_at = excluded.updated_at",
)
.bind(record.id.to_string())
.bind(data)
.bind(record.expiry_date.unix_timestamp())
.bind(user_id)
.bind(&now)
.bind(now)
.execute(&self.pool)
.await
.map_err(store_error)?;
Ok(())
}
async fn load(&self, id: &Id) -> session_store::Result<Option<Record>> {
let row =
sqlx::query("SELECT data, expiry FROM sessions WHERE id = ? AND expiry > unixepoch()")
.bind(id.to_string())
.fetch_optional(&self.pool)
.await
.map_err(store_error)?;
row.map(|row| {
let data = serde_json::from_str(&row.get::<String, _>("data")).map_err(store_error)?;
let expiry_date =
OffsetDateTime::from_unix_timestamp(row.get("expiry")).map_err(store_error)?;
Ok(Record {
id: *id,
data,
expiry_date,
})
})
.transpose()
}
async fn delete(&self, id: &Id) -> session_store::Result<()> {
sqlx::query("DELETE FROM sessions WHERE id = ?")
.bind(id.to_string())
.execute(&self.pool)
.await
.map_err(store_error)?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
#[serde(default)]
pub next: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum BackendError {
#[error(transparent)]
Db(#[from] crate::db::DbError),
#[error("password verification task failed: {0}")]
Join(#[from] tokio::task::JoinError),
}
#[derive(Clone)]
pub struct Backend {
db: Db,
}
impl Backend {
pub fn new(db: Db) -> Self {
Self { db }
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = BackendError;
async fn authenticate(&self, creds: Credentials) -> Result<Option<User>, BackendError> {
let user = users::find_by_username(&self.db, &creds.username).await?;
let hash = user
.as_ref()
.map(|user| user.password_hash.clone())
.unwrap_or_else(|| DUMMY_HASH.to_string());
let password = creds.password;
let valid =
tokio::task::spawn_blocking(move || users::verify_password(&hash, &password)).await?;
Ok(user.filter(|user| valid && !user.disabled))
}
async fn get_user(&self, id: &i64) -> Result<Option<User>, BackendError> {
Ok(users::find_by_id(&self.db, *id)
.await?
.filter(|user| !user.disabled))
}
}
impl AuthzBackend for Backend {
type Permission = Role;
async fn get_user_permissions(&self, user: &User) -> Result<HashSet<Role>, BackendError> {
let permissions = match user.role {
Role::Admin => [Role::User, Role::Admin].into_iter().collect(),
Role::User => [Role::User].into_iter().collect(),
};
Ok(permissions)
}
}
pub type AuthSession = axum_login::AuthSession<Backend>;
#[derive(Debug, Clone)]
pub struct Viewer {
pub id: i64,
pub username: String,
pub role: Role,
}
impl From<User> for Viewer {
fn from(user: User) -> Self {
Self {
id: user.id,
username: user.username,
role: user.role,
}
}
}
pub fn valid_next(next: Option<&str>) -> &str {
next.filter(|next| next.starts_with('/') && !next.starts_with("//"))
.unwrap_or("/")
}
pub async fn require_same_origin(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if request.method() != axum::http::Method::POST {
return next.run(request).await;
}
let headers = request.headers();
if let Some(site) = headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
if !matches!(site, "same-origin" | "none") {
return StatusCode::FORBIDDEN.into_response();
}
} else if let Some(origin) = request_origin(headers) {
let config = state.config();
let public_origin = url::Url::parse(&config.server.public_url)
.ok()
.map(|url| url.origin().ascii_serialization());
let host_origin = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.map(|host| format!("{}://{host}", forwarded_scheme(headers)));
if public_origin.as_deref() != Some(origin.as_str())
&& host_origin.as_deref() != Some(origin.as_str())
{
return StatusCode::FORBIDDEN.into_response();
}
}
next.run(request).await
}
#[derive(Debug, Default, Deserialize)]
pub struct LoginQuery {
#[serde(default)]
next: Option<String>,
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
page: Page,
next: String,
error: String,
}
#[derive(Template)]
#[template(path = "account.html")]
struct AccountTemplate {
page: Page,
error: String,
}
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
let viewer = auth.user().await.map(Viewer::from);
Html(LoginTemplate {
page: Page::new("Sign in", viewer, "login"),
next: valid_next(query.next.as_deref()).to_string(),
error: String::new(),
})
.into_response()
}
pub async fn login(
State(state): State<AppState>,
auth: AuthSession,
Form(credentials): Form<Credentials>,
) -> Result<Response, WebError> {
let destination = valid_next(credentials.next.as_deref()).to_string();
match auth
.authenticate(credentials)
.await
.map_err(|error| WebError::Internal(error.into()))?
{
Some(user) => {
auth.login(&user)
.await
.map_err(|error| WebError::Internal(error.into()))?;
sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?")
.bind(fmt_ts(jiff::Timestamp::now()))
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(crate::db::DbError::from)?;
Ok(axum::response::Redirect::to(&destination).into_response())
}
None => Ok((
StatusCode::UNAUTHORIZED,
Html(LoginTemplate {
page: Page::new("Sign in", None, "login"),
next: destination,
error: "invalid username or password".into(),
}),
)
.into_response()),
}
}
pub async fn logout(auth: AuthSession) -> Result<Response, WebError> {
auth.logout()
.await
.map_err(|error| WebError::Internal(error.into()))?;
Ok(axum::response::Redirect::to("/").into_response())
}
pub async fn account(auth: AuthSession) -> Result<Response, WebError> {
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/account".into(),
})?;
Ok(Html(AccountTemplate {
page: Page::new("Account", Some(user.into()), "account"),
error: String::new(),
})
.into_response())
}
#[derive(Debug, Deserialize)]
pub struct PasswordForm {
current_password: String,
new_password: String,
confirm_password: String,
}
pub async fn change_password(
State(state): State<AppState>,
auth: AuthSession,
Form(form): Form<PasswordForm>,
) -> Result<Response, WebError> {
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/account".into(),
})?;
let hash = user.password_hash.clone();
let current = form.current_password;
let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, &current))
.await
.map_err(|error| WebError::Internal(error.into()))?;
let problem = if !valid {
Some("current password is incorrect".to_string())
} else if form.new_password != form.confirm_password {
Some("the new passwords do not match".to_string())
} else {
users::validate_password(&form.new_password)
.err()
.map(|error| error.to_string())
};
if let Some(error) = problem {
return Ok((
StatusCode::BAD_REQUEST,
Html(AccountTemplate {
page: Page::new("Account", Some(user.into()), "account"),
error,
}),
)
.into_response());
}
let password = form.new_password;
let password_hash = tokio::task::spawn_blocking(move || users::hash_password(&password))
.await
.map_err(|error| WebError::Internal(error.into()))?;
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&password_hash)
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(crate::db::DbError::from)?;
SqliteSessionStore::new(state.db.pool().clone())
.delete_for_user(user.id)
.await
.map_err(crate::db::DbError::from)?;
let mut updated = user;
updated.password_hash = password_hash;
auth.login(&updated)
.await
.map_err(|error| WebError::Internal(error.into()))?;
Ok(axum::response::Redirect::to("/account").into_response())
}
pub async fn logout_everywhere(
State(state): State<AppState>,
auth: AuthSession,
) -> Result<Response, WebError> {
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/account".into(),
})?;
SqliteSessionStore::new(state.db.pool().clone())
.delete_for_user(user.id)
.await
.map_err(crate::db::DbError::from)?;
auth.logout()
.await
.map_err(|error| WebError::Internal(error.into()))?;
Ok(axum::response::Redirect::to("/").into_response())
}
fn forwarded_scheme(headers: &axum::http::HeaderMap) -> &str {
headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.unwrap_or("http")
}
fn request_origin(headers: &axum::http::HeaderMap) -> Option<String> {
if let Some(origin) = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
return Some(origin.trim_end_matches('/').to_string());
}
let referer = headers
.get(header::REFERER)
.and_then(|value| value.to_str().ok())?;
let url = url::Url::from_str(referer).ok()?;
Some(url.origin().ascii_serialization())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use axum_login::tower_sessions::SessionStore;
use serde_json::json;
use time::Duration;
use super::*;
async fn store() -> (tempfile::TempDir, Db, SqliteSessionStore) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
let store = SqliteSessionStore::new(db.pool().clone());
(dir, db, store)
}
fn record(user_id: Option<i64>, expiry: OffsetDateTime) -> Record {
let mut data = HashMap::new();
if let Some(user_id) = user_id {
data.insert(
"axum-login.data".into(),
json!({"user_id": user_id, "auth_hash": [1, 2, 3]}),
);
}
Record {
id: Id::default(),
data,
expiry_date: expiry,
}
}
#[tokio::test]
async fn session_store_round_trips_denormalizes_and_deletes() {
let (_dir, db, store) = store().await;
let user = users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let active = record(
Some(user.id),
OffsetDateTime::now_utc() + Duration::hours(1),
);
store.save(&active).await.unwrap();
let loaded = store.load(&active.id).await.unwrap().unwrap();
assert_eq!(loaded.id, active.id);
assert_eq!(loaded.data, active.data);
assert_eq!(
loaded.expiry_date.unix_timestamp(),
active.expiry_date.unix_timestamp()
);
let denormalized: Option<i64> =
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
.bind(active.id.to_string())
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(denormalized, Some(user.id));
assert_eq!(store.delete_for_user(user.id).await.unwrap(), 1);
assert!(store.load(&active.id).await.unwrap().is_none());
let anonymous = record(None, OffsetDateTime::now_utc() + Duration::hours(1));
store.save(&anonymous).await.unwrap();
let denormalized: Option<i64> =
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
.bind(anonymous.id.to_string())
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(denormalized, None);
store.delete(&anonymous.id).await.unwrap();
assert!(store.load(&anonymous.id).await.unwrap().is_none());
}
#[tokio::test]
async fn expired_sessions_are_hidden_and_pruned_and_errors_surface() {
let (_dir, db, store) = store().await;
let expired = record(None, OffsetDateTime::now_utc() - Duration::seconds(1));
store.save(&expired).await.unwrap();
assert!(store.load(&expired.id).await.unwrap().is_none());
assert_eq!(store.delete_expired().await.unwrap(), 1);
db.close().await;
let error = store.save(&record(None, OffsetDateTime::now_utc())).await;
assert!(matches!(error, Err(session_store::Error::Backend(_))));
}
#[test]
fn next_targets_must_be_same_site_paths() {
assert_eq!(valid_next(Some("/dashboard")), "/dashboard");
assert_eq!(valid_next(Some("//evil.example/")), "/");
assert_eq!(valid_next(Some("https://evil.example/")), "/");
}
}
+35
View File
@@ -0,0 +1,35 @@
:root { --bg:#fbfaf6; --fg:#171713; --muted:#68665f; --rule:#c9c5b9; --accent:#8b1e1e; --loved:#286a3b; --good:#34688a; --down:#943b35; color-scheme:light dark; }
@media (prefers-color-scheme:dark) { :root { --bg:#171714; --fg:#eeeae0; --muted:#aaa69b; --rule:#4c4a44; --accent:#ef8c82; --loved:#75c58a; --good:#75aed0; --down:#e5867d; } }
* { box-sizing:border-box; }
body { margin:0 auto; padding:0 1rem; background:var(--bg); color:var(--fg); font:1rem/1.55 Georgia,serif; }
a { color:var(--accent); }
.masthead { max-width:72ch; margin:1.5rem auto .4rem; border-block:3px double var(--fg); padding:.45rem 0; text-align:center; font-size:2rem; font-weight:700; }
.masthead a { color:inherit; text-decoration:none; }
.primary,.admin,footer { max-width:72ch; margin:.7rem auto; text-align:center; color:var(--muted); }
.admin { max-width:1200px; font-family:system-ui,sans-serif; }
.flash { max-width:72rem; margin:1rem auto; padding:.75rem 1rem; border:1px solid var(--rule); }
main { min-height:70vh; }
.reading { max-width:72ch; margin:2rem auto; }
.narrow { max-width:34rem; }
.dateline,.stats,.byline,.comments,.strap { 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; }
form { display:grid; gap:.8rem; margin:1.5rem 0; }
label { display:grid; gap:.25rem; }
input,textarea,button { font:inherit; padding:.45rem; }
.error { color:var(--down); }
.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
.scroll-x { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
thead { position:sticky; top:0; background:var(--bg); }
.badge { border:1px solid currentColor; border-radius:999px; padding:.1rem .45rem; }
.badge.selected,.badge.loved { color:var(--loved); } .badge.good,.badge.assessed,.badge.triaged { color:var(--good); } .badge.down,.badge.excluded { color:var(--down); }
.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared { color:var(--muted); }
.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; }
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
+11
View File
@@ -0,0 +1,11 @@
document.addEventListener("submit", (event) => {
const message = event.target.dataset.confirm;
if (message && !window.confirm(message)) event.preventDefault();
});
document.querySelectorAll("details[id]").forEach((details) => {
try {
const key = "details:" + details.id;
details.open = localStorage.getItem(key) === "open";
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
} catch (_) {}
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="6" fill="#fbfaf6"/><path d="M13 12h38v40H13z" fill="none" stroke="#171713" stroke-width="3"/><path d="M20 21h24M20 29h24M20 37h18M20 45h20" stroke="#8b1e1e" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 275 B

+1
View File
@@ -0,0 +1 @@
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Account</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/account/password"><label>Current password <input type="password" name="current_password" required></label><label>New password <input type="password" name="new_password" minlength="12" required></label><label>Confirm password <input type="password" name="confirm_password" minlength="12" required></label><button>Change password</button></form><form method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button>Sign out everywhere</button></form><form method="post" action="/logout"><button>Sign out</button></form></section>{% endblock %}
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard"><h1>Overview</h1><p>The dashboard foundation is ready. Run and article views land in the next dashboard step.</p></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>Issue archive</h1>{% for month in months %}<h2>{{ month.label }}</h2><ul>{% for issue in month.issues %}<li><a href="/issues/{{ issue.date }}">{{ issue.display_date }}</a> · No. {{ issue.issue_number }} · {{ issue.article_count }} articles</li>{% endfor %}</ul>{% endfor %}</section>{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "layout.html" %}{% block content %}<article class="reading issue">
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size_bytes }} bytes)</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }} · The Daily EPUB</title>
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}">
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg">
</head>
<body>
<header class="masthead"><a href="/">The Daily EPUB</a></header>
<nav class="primary"><a href="/">Latest</a> · <a href="/issues">Archive</a> · <a href="/feed.xml">Feed</a> · {% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin"><a href="/dashboard">Overview</a> · <a href="/dashboard/runs">Runs</a> · <a href="/dashboard/articles">Articles</a> · <a href="/dashboard/ratings">Ratings</a> · <a href="/dashboard/profile">Profile</a> · <a href="/dashboard/stats">Stats</a> · <a href="/dashboard/jobs">Jobs</a> · <a href="/dashboard/settings">Settings</a> · <a href="/dashboard/users">Users</a></nav>{% endif %}
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
<main>{% block content %}{% endblock %}</main>
<footer>daily-epub {{ page.version }}</footer>
<script src="/static/app.js?v={{ page.version }}"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Sign in</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label>Username <input name="username" autocomplete="username" required></label><label>Password <input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section>{% endblock %}
+312
View File
@@ -0,0 +1,312 @@
use std::fmt;
use std::str::FromStr;
use jiff::Timestamp;
use sqlx::Row;
use crate::db::{Db, DbError, fmt_ts, parse_ts};
pub const MIN_PASSWORD_LEN: usize = 12;
pub const MAX_PASSWORD_LEN: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
User,
Admin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Admin => "admin",
}
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Role {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"user" => Ok(Self::User),
"admin" => Ok(Self::Admin),
_ => Err(format!("invalid role {value:?}; expected user or admin")),
}
}
}
#[derive(Clone)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub role: Role,
pub disabled: bool,
pub created_at: Timestamp,
pub last_login_at: Option<Timestamp>,
}
impl fmt::Debug for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password_hash", &"[REDACTED]")
.field("role", &self.role)
.field("disabled", &self.disabled)
.field("created_at", &self.created_at)
.field("last_login_at", &self.last_login_at)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct UserListRow {
pub user: User,
pub open_sessions: i64,
}
pub fn validate_username(username: &str) -> anyhow::Result<()> {
if username.is_empty()
|| username.len() > 32
|| !username
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'))
{
anyhow::bail!("username must be 1-32 characters from A-Z, a-z, 0-9, '.', '_' or '-'");
}
Ok(())
}
pub fn validate_password(password: &str) -> anyhow::Result<()> {
if password.len() < MIN_PASSWORD_LEN {
anyhow::bail!("password must be at least {MIN_PASSWORD_LEN} characters");
}
if password.len() > MAX_PASSWORD_LEN {
anyhow::bail!("password must be at most {MAX_PASSWORD_LEN} characters");
}
Ok(())
}
pub fn hash_password(plain: &str) -> String {
password_auth::generate_hash(plain)
}
pub fn verify_password(hash: &str, plain: &str) -> bool {
password_auth::verify_password(plain, hash).is_ok()
}
pub async fn find_by_username(db: &Db, username: &str) -> Result<Option<User>, DbError> {
let row = sqlx::query(
"SELECT id, username, password_hash, role, disabled, created_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE",
)
.bind(username)
.fetch_optional(db.pool())
.await?;
row.as_ref().map(user_from_row).transpose()
}
pub async fn find_by_id(db: &Db, id: i64) -> Result<Option<User>, DbError> {
let row = sqlx::query(
"SELECT id, username, password_hash, role, disabled, created_at, last_login_at
FROM users WHERE id = ?",
)
.bind(id)
.fetch_optional(db.pool())
.await?;
row.as_ref().map(user_from_row).transpose()
}
pub async fn add(db: &Db, username: &str, password: &str, admin: bool) -> anyhow::Result<User> {
validate_username(username)?;
validate_password(password)?;
if find_by_username(db, username).await?.is_some() {
anyhow::bail!("user {username:?} already exists");
}
let hash = hash_password(password);
let created_at = Timestamp::now();
let role = if admin { Role::Admin } else { Role::User };
let id: i64 = sqlx::query_scalar(
"INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(username)
.bind(&hash)
.bind(role.as_str())
.bind(fmt_ts(created_at))
.fetch_one(db.pool())
.await?;
Ok(User {
id,
username: username.to_string(),
password_hash: hash,
role,
disabled: false,
created_at,
last_login_at: None,
})
}
pub async fn passwd(db: &Db, username: &str, password: &str) -> anyhow::Result<u64> {
validate_password(password)?;
let hash = hash_password(password);
let result =
sqlx::query("UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE")
.bind(hash)
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())?;
logout(db, username).await
}
pub async fn set_role(db: &Db, username: &str, role: Role) -> anyhow::Result<()> {
let result = sqlx::query("UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE")
.bind(role.as_str())
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())
}
pub async fn set_disabled(db: &Db, username: &str, disabled: bool) -> anyhow::Result<u64> {
let result = sqlx::query("UPDATE users SET disabled = ? WHERE username = ? COLLATE NOCASE")
.bind(disabled)
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())?;
if disabled {
logout(db, username).await
} else {
Ok(0)
}
}
pub async fn logout(db: &Db, username: &str) -> anyhow::Result<u64> {
let Some(user) = find_by_username(db, username).await? else {
anyhow::bail!("user {username:?} was not found");
};
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
.bind(user.id)
.execute(db.pool())
.await?;
Ok(result.rows_affected())
}
pub async fn list(db: &Db) -> anyhow::Result<Vec<UserListRow>> {
let rows = sqlx::query(
"SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.created_at,
u.last_login_at, COUNT(s.id) AS open_sessions
FROM users u LEFT JOIN sessions s ON s.user_id = u.id AND s.expiry > unixepoch()
GROUP BY u.id ORDER BY u.username COLLATE NOCASE",
)
.fetch_all(db.pool())
.await?;
rows.iter()
.map(|row| {
Ok(UserListRow {
user: user_from_row(row)?,
open_sessions: row.get("open_sessions"),
})
})
.collect()
}
fn require_one(username: &str, count: u64) -> anyhow::Result<()> {
if count == 0 {
anyhow::bail!("user {username:?} was not found");
}
Ok(())
}
fn user_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<User, DbError> {
let role_raw: String = row.get("role");
let role = role_raw.parse().map_err(|_| DbError::Decode {
column: "users.role",
value: role_raw,
})?;
Ok(User {
id: row.get("id"),
username: row.get("username"),
password_hash: row.get("password_hash"),
role,
disabled: row.get("disabled"),
created_at: parse_ts("users.created_at", &row.get::<String, _>("created_at"))?,
last_login_at: row
.get::<Option<String>, _>("last_login_at")
.map(|value| parse_ts("users.last_login_at", &value))
.transpose()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_hashes_round_trip_and_fail_safely() {
let hash = hash_password("correct horse battery");
assert!(verify_password(&hash, "correct horse battery"));
assert!(!verify_password(&hash, "wrong password"));
assert!(!verify_password("not a phc string", "anything"));
assert!(
!format!(
"{:?}",
User {
id: 1,
username: "reader".into(),
password_hash: hash.clone(),
role: Role::User,
disabled: false,
created_at: Timestamp::now(),
last_login_at: None,
}
)
.contains(&hash)
);
}
#[tokio::test]
async fn user_operations_validate_and_are_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
assert!(add(&db, "reader", "too-short", false).await.is_err());
let user = add(&db, "Reader", "correct horse battery", true)
.await
.unwrap();
assert_eq!(user.role, Role::Admin);
assert!(
add(&db, "reader", "another valid password", false)
.await
.is_err()
);
set_role(&db, "READER", Role::User).await.unwrap();
assert_eq!(
find_by_username(&db, "reader").await.unwrap().unwrap().role,
Role::User
);
passwd(&db, "reader", "a replacement password")
.await
.unwrap();
set_disabled(&db, "reader", true).await.unwrap();
assert!(
find_by_username(&db, "reader")
.await
.unwrap()
.unwrap()
.disabled
);
set_disabled(&db, "reader", false).await.unwrap();
}
}