Replaces the hand-written stylesheet with a Tailwind CSS v4 source (src/web/tailwind.css) whose minified build is committed as src/web/static/app.css and still embedded with include_str!, so the binary and the test suite never need Node. package.json pins tailwindcss and @tailwindcss/cli to 4.3.3 and adds css, css:watch and css:check. Colours are semantic CSS variables swapped per theme and exposed to Tailwind through @theme inline, so templates write bg-paper/text-ink/border-rule once and both themes work. theme.js runs synchronously in <head> to set data-theme before paint; app.js cycles the toggle system -> light -> dark and persists the choice. Newsreader is self-hosted and served from web::static_asset with the same ETag/304 logic as the other assets. Every reader template is restyled: ears row, centred masthead with its rules, tracked section labels, the drop cap on The Brief, "Why it's here" with its accent bar, the segmented rating control, prev/next cards, and the colophon as a two-column definition grid. Dashboard templates are untouched (step 3). Also fixes a .block collision with Tailwind's display utility, the SVG `hidden` property that never toggled the theme icon, and points the dev seed's profile_path at ./dev so previewing cannot rewrite data/profile.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
1062 lines
35 KiB
Rust
1062 lines
35 KiB
Rust
pub mod dashboard;
|
|
pub mod issue;
|
|
pub mod public;
|
|
pub mod rate;
|
|
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;
|
|
use axum_login::tower_sessions::Session;
|
|
|
|
#[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>,
|
|
/// `ExecMainStartTimestamp`, as systemd prints it; empty when never run.
|
|
pub started: Option<String>,
|
|
/// `ExecMainExitTimestamp`, as systemd prints it.
|
|
pub exited: Option<String>,
|
|
}
|
|
|
|
impl UnitStatus {
|
|
/// The unit ran and stopped with a failure result (`exit-code`, `failed`,
|
|
/// `signal`, `timeout`, …); a never-started unit reports `success`.
|
|
pub fn exited_unsuccessfully(&self) -> bool {
|
|
matches!(self.active_state.as_str(), "inactive" | "failed")
|
|
&& !self.result.is_empty()
|
|
&& self.result != "success"
|
|
}
|
|
}
|
|
|
|
#[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: records every call and answers with
|
|
/// scripted results (`start` succeeds, `status` is inactive/success and `log`
|
|
/// is empty unless told otherwise).
|
|
#[derive(Debug, Default)]
|
|
pub struct MockRunner {
|
|
calls: Mutex<Vec<String>>,
|
|
start_error: Mutex<Option<String>>,
|
|
statuses: Mutex<std::collections::HashMap<String, UnitStatus>>,
|
|
log_text: Mutex<String>,
|
|
}
|
|
|
|
impl MockRunner {
|
|
pub fn calls(&self) -> Vec<String> {
|
|
self.calls.lock().expect("mock runner lock").clone()
|
|
}
|
|
|
|
/// Make every `start` fail with `message` (`None` restores success).
|
|
pub fn fail_starts(&self, message: Option<&str>) {
|
|
*self.start_error.lock().expect("mock runner lock") = message.map(str::to_string);
|
|
}
|
|
|
|
/// Script the `status` answer for one unit.
|
|
pub fn set_status(&self, unit: &str, status: UnitStatus) {
|
|
self.statuses
|
|
.lock()
|
|
.expect("mock runner lock")
|
|
.insert(unit.to_string(), status);
|
|
}
|
|
|
|
/// Script the journal text every `log` call returns.
|
|
pub fn set_log(&self, text: &str) {
|
|
*self.log_text.lock().expect("mock runner lock") = text.to_string();
|
|
}
|
|
|
|
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}"));
|
|
match self.start_error.lock().expect("mock runner lock").clone() {
|
|
Some(message) => Err(message),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
|
|
self.record(format!("status {unit}"));
|
|
Ok(self
|
|
.statuses
|
|
.lock()
|
|
.expect("mock runner lock")
|
|
.get(unit)
|
|
.cloned()
|
|
.unwrap_or_else(|| UnitStatus {
|
|
active_state: "inactive".into(),
|
|
sub_state: "dead".into(),
|
|
result: "success".into(),
|
|
..UnitStatus::default()
|
|
}))
|
|
}
|
|
|
|
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
|
|
self.record(format!("log {unit} {lines}"));
|
|
Ok(self.log_text.lock().expect("mock runner lock").clone())
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
impl WebState {
|
|
/// Config reload on mtime (dashboard plan §4.2): when `config_path`'s
|
|
/// modification time differs from the cached one, re-run `Config::load`
|
|
/// and swap the live config. Returns `Ok(true)` when a reload happened,
|
|
/// `Ok(false)` when nothing changed or no file is configured, and the
|
|
/// load error when the file on disk no longer loads — the previous
|
|
/// config stays live and the cached mtime is left alone so the next call
|
|
/// tries again. Called by the settings page and by job starts.
|
|
pub fn reload_if_changed(
|
|
state: &crate::server::AppState,
|
|
) -> Result<bool, crate::config::ConfigError> {
|
|
let Some(path) = state.config_path.as_deref() else {
|
|
return Ok(false);
|
|
};
|
|
let mtime = std::fs::metadata(path)
|
|
.and_then(|metadata| metadata.modified())
|
|
.ok();
|
|
let mut cached = state
|
|
.web
|
|
.config_mtime
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
if *cached == mtime {
|
|
return Ok(false);
|
|
}
|
|
let config = crate::config::Config::load(Some(path))?;
|
|
match state.config.write() {
|
|
Ok(mut live) => *live = std::sync::Arc::new(config),
|
|
Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config),
|
|
}
|
|
*cached = mtime;
|
|
tracing::info!(path = %path.display(), "reloaded configuration from disk");
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
#[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 async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
|
|
session
|
|
.remove("flash")
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))
|
|
}
|
|
|
|
pub struct Html<T: Template>(pub T);
|
|
|
|
impl<T: Template> IntoResponse for Html<T> {
|
|
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");
|
|
error_page_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Server error",
|
|
"The request could not be completed.",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response {
|
|
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()
|
|
}
|
|
|
|
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!(),
|
|
};
|
|
error_page_response(status, heading, message)
|
|
}
|
|
}
|
|
|
|
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 full_issues = axum::Router::new()
|
|
.route("/issues/{date}/articles/{article_id}", get(issue::article))
|
|
.route("/issues/{date}/world", get(issue::world))
|
|
.route("/issues/{date}/behind", get(issue::behind))
|
|
.route_layer(login_required!(
|
|
session::Backend,
|
|
login_url = "/login",
|
|
redirect_field = "next"
|
|
))
|
|
.route_layer(from_fn(map_forbidden));
|
|
let dashboard = axum::Router::new()
|
|
.merge(dashboard::router())
|
|
.route("/rate", post(rate::post))
|
|
.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(full_issues)
|
|
.merge(dashboard)
|
|
.fallback(|| async { WebError::NotFound })
|
|
}
|
|
|
|
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: (&str, &'static [u8]) = match file.as_str() {
|
|
"app.css" => (
|
|
"text/css; charset=utf-8",
|
|
include_str!("static/app.css").as_bytes(),
|
|
),
|
|
"app.js" => (
|
|
"application/javascript; charset=utf-8",
|
|
include_str!("static/app.js").as_bytes(),
|
|
),
|
|
"theme.js" => (
|
|
"application/javascript; charset=utf-8",
|
|
include_str!("static/theme.js").as_bytes(),
|
|
),
|
|
"favicon.svg" => (
|
|
"image/svg+xml",
|
|
include_str!("static/favicon.svg").as_bytes(),
|
|
),
|
|
"Newsreader.woff2" => (
|
|
"font/woff2",
|
|
include_bytes!("static/fonts/Newsreader.woff2"),
|
|
),
|
|
"Newsreader-italic.woff2" => (
|
|
"font/woff2",
|
|
include_bytes!("static/fonts/Newsreader-italic.woff2"),
|
|
),
|
|
_ => return WebError::NotFound.into_response(),
|
|
};
|
|
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
|
|
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::BAD_REQUEST);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn not_found_and_server_error_pages_use_the_site_layout() {
|
|
let (_dir, state) = test_state(Config::default()).await;
|
|
let app = router(state);
|
|
let missing = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/no-such-page")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
|
let missing = response_text(missing).await;
|
|
assert!(missing.contains("<!doctype html>"), "{missing}");
|
|
assert!(missing.contains("The Daily EPUB"), "{missing}");
|
|
assert!(missing.contains("That page does not exist"), "{missing}");
|
|
|
|
let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response();
|
|
assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
let failed = response_text(failed).await;
|
|
assert!(failed.contains("<!doctype html>"), "{failed}");
|
|
assert!(failed.contains("The Daily EPUB"), "{failed}");
|
|
assert!(
|
|
failed.contains("The request could not be completed"),
|
|
"{failed}"
|
|
);
|
|
assert!(!failed.contains("fixture failure"), "{failed}");
|
|
}
|
|
}
|