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:
@@ -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(§ion) {
|
||||
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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn empty_issue() -> PublicIssue {
|
||||
PublicIssue {
|
||||
date: "1970-01-01".parse().expect("valid epoch date"),
|
||||
issue_number: 0,
|
||||
display_date: String::new(),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t))
|
||||
.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/")), "/");
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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 (_) {}
|
||||
});
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user