repos
/ status-rust master

status-rust

mirror archived upstream

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

4.0 KB · 124 lines · Rust Raw History
  1use axum::{http::HeaderValue, middleware as axum_middleware, Router};
  2use minijinja::Environment;
  3use sqlx::SqlitePool;
  4use std::path::PathBuf;
  5use std::sync::Arc;
  6use tower_cookies::{CookieManagerLayer, Key};
  7use tower_http::services::ServeDir;
  8use tower_http::set_header::SetResponseHeaderLayer;
  9
 10use crate::pdf::PdfRenderer;
 11use crate::routes;
 12use crate::{db, middleware, templates};
 13
 14#[derive(Clone)]
 15pub struct AppState {
 16    pub env: Arc<Environment<'static>>,
 17    pub pool: SqlitePool,
 18    pub cookie_key: Key,
 19    pub config: Arc<Config>,
 20    pub pdf_renderer: Arc<PdfRenderer>,
 21}
 22
 23#[derive(Debug, Clone)]
 24pub struct Config {
 25    pub root: PathBuf,
 26    pub data_dir: PathBuf,
 27    pub password: String,
 28    pub base_url: String,
 29    pub alert_email: Option<String>,
 30    pub discord_webhook_url: Option<String>,
 31}
 32
 33impl AppState {
 34    pub async fn from_env() -> anyhow::Result<Self> {
 35        let root: PathBuf = std::env::var("STATUS_ROOT")
 36            .map(PathBuf::from)
 37            .unwrap_or_else(|_| PathBuf::from("."));
 38        let data_dir = std::env::var("STATUS_DATA_DIR")
 39            .map(PathBuf::from)
 40            .unwrap_or_else(|_| root.join("data"));
 41        std::fs::create_dir_all(&data_dir)?;
 42
 43        let password = std::env::var("STATUS_PASSWORD")
 44            .ok()
 45            .filter(|p| !p.is_empty())
 46            .ok_or_else(|| {
 47                anyhow::anyhow!(
 48                    "STATUS_PASSWORD is not set; refusing to start an internet-facing \
 49                     server with a guessable default"
 50                )
 51            })?;
 52        let base_url = std::env::var("BASE_URL").unwrap_or_default();
 53        let alert_email = std::env::var("ALERT_EMAIL").ok().filter(|s| !s.is_empty());
 54        let discord_webhook_url = std::env::var("DISCORD_WEBHOOK_URL")
 55            .ok()
 56            .filter(|s| !s.is_empty());
 57
 58        let cookie_secret = std::env::var("STATUS_COOKIE_SECRET").unwrap_or_else(|_| {
 59            // 64+ bytes derived from password if no secret provided. For a single-user
 60            // self-hosted app this is fine; setting STATUS_COOKIE_SECRET is preferred.
 61            use sha2::{Digest, Sha512};
 62            let mut h = Sha512::new();
 63            h.update(b"status-cookie:");
 64            h.update(password.as_bytes());
 65            let digest = h.finalize();
 66            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)
 67        });
 68        let cookie_key = Key::from(cookie_secret.as_bytes());
 69
 70        let pool = db::init(&data_dir).await?;
 71
 72        let templates_dir = root.join("templates");
 73        let manifest_path = root.join("dist/.vite/manifest.json");
 74        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));
 75
 76        let pdf_renderer = Arc::new(PdfRenderer::new(root.clone()));
 77
 78        let config = Arc::new(Config {
 79            root,
 80            data_dir,
 81            password,
 82            base_url,
 83            alert_email,
 84            discord_webhook_url,
 85        });
 86
 87        Ok(Self {
 88            env,
 89            pool,
 90            cookie_key,
 91            config,
 92            pdf_renderer,
 93        })
 94    }
 95}
 96
 97pub fn router(state: AppState) -> Router {
 98    let dist_dir = state.config.root.join("dist");
 99
100    let static_cache = SetResponseHeaderLayer::if_not_present(
101        axum::http::header::CACHE_CONTROL,
102        HeaderValue::from_static("public, max-age=31536000"),
103    );
104
105    Router::new()
106        .merge(routes::home::router())
107        .merge(routes::auth::router())
108        .merge(routes::seo::router())
109        .merge(routes::properties::router())
110        // routes::dashboard holds the UUID `/{property_id}` catch-all; merge
111        // last so named routes win the match.
112        .merge(routes::dashboard::router())
113        .nest_service(
114            "/static",
115            tower::ServiceBuilder::new()
116                .layer(static_cache)
117                .service(ServeDir::new(&dist_dir)),
118        )
119        .fallback(middleware::not_found)
120        .layer(CookieManagerLayer::new())
121        .layer(axum_middleware::from_fn(middleware::log_requests))
122        .with_state(state)
123}