Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1use axum::{
2 extract::{Form, State},
3 http::StatusCode,
4 response::{IntoResponse, Redirect, Response},
5 routing::{get, post},
6 Router,
7};
8use chrono::Utc;
9use serde::Deserialize;
10use tower_cookies::{
11 cookie::{time::Duration, SameSite},
12 Cookie, Cookies,
13};
14
15use crate::render::render;
16use crate::AppState;
17
18const COOKIE_NAME: &str = "session";
19// 30 days. Matches the cookie max-age the browser stores.
20const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
21
22pub fn router() -> Router<AppState> {
23 Router::new()
24 .route("/login", get(login_form).post(login_submit))
25 .route("/logout", post(logout))
26}
27
28#[derive(Debug, Deserialize)]
29pub struct LoginForm {
30 pub password: String,
31 #[serde(default)]
32 pub next: Option<String>,
33}
34
35/// Returns true if the request carries a valid, unexpired signed session
36/// cookie. Used by every auth-gated route module.
37pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {
38 let signed = cookies.signed(&state.cookie_key);
39 let Some(c) = signed.get(COOKIE_NAME) else { return false };
40 let value = c.value();
41 let Some((flag, exp_str)) = value.split_once(':') else { return false };
42 if flag != "1" {
43 return false;
44 }
45 let Ok(exp) = exp_str.parse::<i64>() else { return false };
46 Utc::now().timestamp() < exp
47}
48
49fn render_login(state: &AppState, error: Option<&str>) -> Response {
50 render(
51 state,
52 "registration/login.html",
53 "/login",
54 false,
55 minijinja::context! {
56 page => minijinja::context! {
57 title => "Log in",
58 description => "Log in to your dashboard.",
59 },
60 title => "Log in",
61 description => "Log in to your dashboard.",
62 error => error,
63 next => "/properties",
64 },
65 )
66}
67
68pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {
69 if is_authenticated(&cookies, &state) {
70 return Redirect::to("/properties").into_response();
71 }
72 render_login(&state, None)
73}
74
75/// Compare fixed-length digests so the comparison cost does not depend on
76/// where the supplied password diverges (or on its length).
77fn password_matches(supplied: &str, actual: &str) -> bool {
78 use sha2::{Digest, Sha512};
79 let a = Sha512::digest(supplied.as_bytes());
80 let b = Sha512::digest(actual.as_bytes());
81 a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
82}
83
84pub async fn login_submit(
85 State(state): State<AppState>,
86 cookies: Cookies,
87 Form(form): Form<LoginForm>,
88) -> Response {
89 if !password_matches(&form.password, &state.config.password) {
90 // Flat delay on every failure keeps online brute force impractically
91 // slow for a single-password app without tracking per-IP state.
92 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
93 let html = render_login(&state, Some("Invalid password."));
94 return (StatusCode::UNAUTHORIZED, html).into_response();
95 }
96 let exp = Utc::now().timestamp() + SESSION_TTL_SECS;
97 let value = format!("1:{exp}");
98 let cookie = Cookie::build((COOKIE_NAME, value))
99 .path("/")
100 .http_only(true)
101 .same_site(SameSite::Strict)
102 .max_age(Duration::seconds(SESSION_TTL_SECS))
103 .build();
104 cookies.signed(&state.cookie_key).add(cookie);
105 let next = form
106 .next
107 .filter(|n| n.starts_with('/') && !n.starts_with("//"))
108 .unwrap_or_else(|| "/properties".to_string());
109 Redirect::to(&next).into_response()
110}
111
112pub async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
113 let signed = cookies.signed(&state.cookie_key);
114 if let Some(c) = signed.get(COOKIE_NAME) {
115 cookies.remove(c);
116 }
117 Redirect::to("/")
118}