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

20.2 KB · 580 lines · Rust Raw History
  1use axum::{
  2    extract::{Path as AxumPath, Query, State},
  3    http::{header, HeaderMap, StatusCode},
  4    response::{IntoResponse, Json, Redirect, Response},
  5    routing::{get, post},
  6    Router,
  7};
  8use serde::Deserialize;
  9use serde_json::{json, Value};
 10use std::collections::HashMap;
 11use tower_cookies::Cookies;
 12use uuid::Uuid;
 13
 14use crate::models::{
 15    self, count_checks, count_status_codes, count_uptime, ms_to_iso_opt, recent_checks,
 16    PropertyContext, PropertyRow,
 17};
 18use crate::render::{render, render_to_string};
 19use crate::routes::auth::is_authenticated;
 20use crate::AppState;
 21
 22pub fn router() -> Router<AppState> {
 23    Router::new()
 24        .route("/properties/{id}/status", get(property_status))
 25        .route("/properties/{id}/recrawl", post(property_recrawl))
 26        .route(
 27            "/properties/{id}/rerun-lighthouse",
 28            post(property_rerun_lighthouse),
 29        )
 30        // UUID catch-all is the property dashboard. Keep the merge order in
 31        // app::router so the named routes above win the match.
 32        .route("/{property_id}", get(property))
 33}
 34
 35fn forbidden_json() -> Response {
 36    (StatusCode::FORBIDDEN, Json(json!({"error": "forbidden"}))).into_response()
 37}
 38
 39fn not_found_json() -> Response {
 40    (StatusCode::NOT_FOUND, Json(json!({"error": "not_found"}))).into_response()
 41}
 42
 43#[derive(Debug, Deserialize)]
 44pub struct PropertyQuery {
 45    pub report: Option<String>,
 46}
 47
 48/// ASCII-only filename so the Content-Disposition header value always parses;
 49/// a property named "Café" must not panic the report download.
 50fn ascii_filename(name: &str) -> String {
 51    let cleaned: String = name
 52        .chars()
 53        .map(|c| if c.is_ascii_alphanumeric() || " .-_".contains(c) { c } else { '_' })
 54        .collect();
 55    let trimmed = cleaned.trim().trim_matches('.');
 56    if trimmed.is_empty() { "report".to_string() } else { trimmed.to_string() }
 57}
 58
 59pub async fn property(
 60    State(state): State<AppState>,
 61    cookies: Cookies,
 62    AxumPath(property_id): AxumPath<Uuid>,
 63    Query(q): Query<PropertyQuery>,
 64) -> Response {
 65    let row = match models::get_property(&state.pool, property_id).await {
 66        Ok(Some(r)) => r,
 67        _ => return Redirect::to("/properties").into_response(),
 68    };
 69
 70    let authed = is_authenticated(&cookies, &state);
 71    let public = row.is_public != 0;
 72    if !public && !authed {
 73        return Redirect::to("/properties").into_response();
 74    }
 75
 76    let ctx = match build_property_context(&state, &row).await {
 77        Ok(c) => c,
 78        Err(e) => {
 79            tracing::error!("property context: {e:#}");
 80            return (StatusCode::INTERNAL_SERVER_ERROR, "context error").into_response();
 81        }
 82    };
 83
 84    // Per-page graphs (response times, status codes, uptime).
 85    let recent = recent_checks(&state.pool, property_id, 31).await.unwrap_or_default();
 86    let status_response_times: Vec<Value> = recent
 87        .iter()
 88        .rev()
 89        .map(|c| {
 90            json!({
 91                "label": chrono::DateTime::<chrono::Utc>::from_timestamp_millis(c.created_at)
 92                    .map(|d| d.to_rfc3339())
 93                    .unwrap_or_default(),
 94                "total": c.response_ms,
 95                "dns":   c.dns_ms,
 96                "tcp":   c.tcp_ms,
 97                "tls":   c.tls_ms,
 98                "ttfb":  c.ttfb_ms,
 99            })
100        })
101        .collect();
102    let codes = count_status_codes(&state.pool, property_id).await.unwrap_or_default();
103    let status_codes_graph: Vec<Value> = codes
104        .iter()
105        .map(|(code, count)| json!({"label": code, "count": count}))
106        .collect();
107    let (up, down) = count_uptime(&state.pool, property_id).await.unwrap_or((0, 0));
108    let total = up + down;
109    let pct = |n: i64| -> f64 {
110        if total == 0 {
111            0.0
112        } else {
113            (n as f64 / total as f64 * 10000.0).round() / 100.0
114        }
115    };
116    let uptime_graph: Vec<Value> = vec![
117        json!({"label": "Uptime",   "count": pct(up)}),
118        json!({"label": "Downtime", "count": pct(down)}),
119    ];
120
121    let title = ctx.name.clone();
122    let description = format!("Status for {}", ctx.name);
123    let property_value = serde_json::to_value(&ctx).unwrap_or(Value::Null);
124    let insights_groups = group_insights_by_type(&ctx.crawler_insights);
125    let path = format!("/{property_id}");
126
127    // Report formats. Operator-only: the report buttons never render for
128    // anonymous visitors, and PDF rendering is CPU-bound Typst, so an
129    // unauthenticated public page must not be a free PDF-generation endpoint.
130    if let Some(fmt) = q.report.as_deref() {
131        if !authed {
132            return Redirect::to(&path).into_response();
133        }
134        let fmt = if fmt.is_empty() { "pdf" } else { fmt };
135        if fmt == "md" {
136            let extra = minijinja::context! {
137                property => &property_value,
138                title => &title,
139                description => &description,
140            };
141            return match render_to_string(
142                &state,
143                "properties/property_report.md",
144                &path,
145                authed,
146                extra,
147            ) {
148                Ok(body) => {
149                    let mut h = HeaderMap::new();
150                    h.insert(
151                        header::CONTENT_TYPE,
152                        "text/markdown; charset=utf-8".parse().unwrap(),
153                    );
154                    h.insert(
155                        header::CONTENT_DISPOSITION,
156                        format!("inline; filename=\"{}.md\"", ascii_filename(&ctx.name))
157                            .parse()
158                            .unwrap(),
159                    );
160                    (StatusCode::OK, h, body).into_response()
161                }
162                Err(resp) => resp,
163            };
164        }
165        if fmt == "pdf" {
166            let extra = minijinja::context! {
167                property => &property_value,
168                insights_groups => &insights_groups,
169                title => &title,
170                description => &description,
171                base_url => &state.config.base_url,
172                generated_at => chrono::Local::now().format("%Y-%m-%d %H:%M %Z").to_string(),
173            };
174            let typst_source = match render_to_string(
175                &state,
176                "properties/property_report.typ",
177                &path,
178                authed,
179                extra,
180            ) {
181                Ok(s) => s,
182                Err(resp) => return resp,
183            };
184            let renderer = state.pdf_renderer.clone();
185            let result =
186                tokio::task::spawn_blocking(move || renderer.render(typst_source)).await;
187            return match result {
188                Ok(Ok(bytes)) => {
189                    let mut hh = HeaderMap::new();
190                    hh.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());
191                    hh.insert(
192                        header::CONTENT_DISPOSITION,
193                        format!("inline; filename=\"{}.pdf\"", ascii_filename(&ctx.name))
194                            .parse()
195                            .unwrap(),
196                    );
197                    (StatusCode::OK, hh, bytes).into_response()
198                }
199                Ok(Err(e)) => {
200                    tracing::error!("pdf render: {e:#}");
201                    (StatusCode::SERVICE_UNAVAILABLE, "pdf unavailable").into_response()
202                }
203                Err(e) => {
204                    tracing::error!("pdf join: {e}");
205                    (StatusCode::INTERNAL_SERVER_ERROR, "pdf join error").into_response()
206                }
207            };
208        }
209    }
210
211    let extra = minijinja::context! {
212        page => minijinja::context! { title => &title, description => &description },
213        property => &property_value,
214        status_response_times_graph => &status_response_times,
215        status_codes_graph => &status_codes_graph,
216        uptime_graph => &uptime_graph,
217        insights_groups => &insights_groups,
218        title => &title,
219        description => &description,
220    };
221    render(&state, "properties/property.html", &path, authed, extra)
222}
223
224pub async fn build_property_context(
225    state: &AppState,
226    row: &PropertyRow,
227) -> anyhow::Result<PropertyContext> {
228    let id = row.uuid();
229    let recent = recent_checks(&state.pool, id, 100).await?;
230    let total = count_checks(&state.pool, id).await?;
231    let current_status = recent.first().map(|c| c.status_code).unwrap_or(200);
232    let avg_response_time = if recent.is_empty() {
233        0
234    } else {
235        let n = recent.iter().take(31).count() as i64;
236        let sum: i64 = recent.iter().take(31).map(|c| c.response_ms).sum();
237        if n == 0 {
238            0
239        } else {
240            sum / n
241        }
242    };
243    let recent_uptime_pct = if recent.is_empty() {
244        None
245    } else {
246        let up = recent.iter().filter(|c| c.status_code == 200).count() as f64;
247        Some(((up / recent.len() as f64) * 1000.0).round() / 10.0)
248    };
249    let mut tick: Vec<&'static str> = recent
250        .iter()
251        .rev()
252        .map(|c| if c.status_code == 200 { "up" } else { "down" })
253        .collect();
254    tick.truncate(30);
255
256    let latest_headers: HashMap<String, String> = recent
257        .first()
258        .and_then(|c| serde_json::from_str::<HashMap<String, String>>(&c.headers).ok())
259        .unwrap_or_default();
260    let lower: HashMap<String, String> = latest_headers
261        .into_iter()
262        .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
263        .collect();
264
265    let is_https = row.url.starts_with("https://");
266    let invalid_cert = current_status == 526;
267    let has_mime_type = lower.contains_key("content-type");
268    let has_content_sniffing_protection = lower
269        .get("x-content-type-options")
270        .map(|v| v == "nosniff")
271        .unwrap_or(false);
272    let has_clickjack_protection = lower
273        .get("x-frame-options")
274        .map(|v| matches!(v.as_str(), "deny" | "sameorigin" | "allow-from"))
275        .unwrap_or(false);
276    let hides_server_version = !lower.contains_key("server")
277        && !lower.contains_key("x-server")
278        && !lower.contains_key("powered-by")
279        && !lower.contains_key("x-powered-by");
280    let hsts = lower
281        .get("strict-transport-security")
282        .cloned()
283        .unwrap_or_default();
284    let has_hsts = {
285        if hsts.is_empty() {
286            false
287        } else if let Some(re) = regex::Regex::new(r"max-age=(\d+)").ok() {
288            re.captures(&hsts)
289                .and_then(|c| c.get(1))
290                .and_then(|m| m.as_str().parse::<i64>().ok())
291                .map(|m| m >= 31_536_000)
292                .unwrap_or(false)
293        } else {
294            false
295        }
296    };
297    let has_hsts_preload = hsts.to_lowercase().contains("preload");
298    let has_security_issue = !is_https
299        || !has_mime_type
300        || !has_content_sniffing_protection
301        || !has_clickjack_protection
302        || !hides_server_version
303        || !has_hsts
304        || !has_hsts_preload;
305
306    let lighthouse_scores: Value = row
307        .lighthouse_scores
308        .as_ref()
309        .and_then(|s| serde_json::from_str(s).ok())
310        .unwrap_or(Value::Null);
311    let lighthouse_details: Value = row
312        .lighthouse_details
313        .as_ref()
314        .and_then(|s| serde_json::from_str(s).ok())
315        .unwrap_or(Value::Null);
316    let crawler_insights: Value = row
317        .crawler_insights
318        .as_ref()
319        .and_then(|s| serde_json::from_str(s).ok())
320        .unwrap_or(Value::Array(Vec::new()));
321
322    let avg_lighthouse_score: Option<i64> = lighthouse_scores.as_object().and_then(|m| {
323        let scores: Vec<i64> = m.values().filter_map(|v| v.as_i64()).collect();
324        if scores.is_empty() {
325            None
326        } else {
327            Some((scores.iter().sum::<i64>() as f64 / scores.len() as f64).round() as i64)
328        }
329    });
330
331    Ok(PropertyContext {
332        id: row.uuid().to_string(),
333        url: row.url.clone(),
334        name: row.name(),
335        is_public: row.is_public != 0,
336        is_protected: row.is_protected != 0,
337        current_status,
338        avg_response_time,
339        recent_uptime_pct,
340        recent_tick_stream: tick,
341        total_checks: total,
342        crawl_state: row.crawl_state.clone(),
343        crawler_insights,
344        last_crawl_success_at: ms_to_iso_opt(row.last_crawl_success_at),
345        last_crawl_error: row.last_crawl_error.clone(),
346        last_crawl_duration_ms: row.last_crawl_duration_ms,
347        last_crawl_pages_count: row.last_crawl_pages_count,
348        next_run_at_crawler: ms_to_iso_opt(row.next_run_at_crawler),
349        crawl_started_at: ms_to_iso_opt(row.crawl_started_at),
350        lighthouse_state: row.lighthouse_state.clone(),
351        lighthouse_scores,
352        lighthouse_details,
353        last_lighthouse_success_at: ms_to_iso_opt(row.last_lighthouse_success_at),
354        last_lighthouse_error: row.last_lighthouse_error.clone(),
355        last_lighthouse_duration_ms: row.last_lighthouse_duration_ms,
356        next_lighthouse_run_at: ms_to_iso_opt(row.next_lighthouse_run_at),
357        lighthouse_started_at: ms_to_iso_opt(row.lighthouse_started_at),
358        avg_lighthouse_score,
359        alert_state: row.alert_state.clone(),
360        created_at: models::ms_to_iso(row.created_at),
361        updated_at: models::ms_to_iso(row.updated_at),
362        is_https,
363        invalid_cert,
364        has_mime_type,
365        has_content_sniffing_protection,
366        has_clickjack_protection,
367        hides_server_version,
368        has_hsts,
369        has_hsts_preload,
370        has_security_issue,
371    })
372}
373
374fn group_insights_by_type(insights: &Value) -> Vec<Value> {
375    use std::collections::BTreeMap;
376    let arr = match insights.as_array() {
377        Some(a) => a,
378        None => return Vec::new(),
379    };
380    let mut buckets: BTreeMap<String, Vec<Value>> = BTreeMap::new();
381    for item in arr {
382        let t = item
383            .get("type")
384            .and_then(|v| v.as_str())
385            .unwrap_or("other")
386            .to_string();
387        buckets.entry(t).or_default().push(item.clone());
388    }
389    // Sort each bucket so errors come before warnings before info, matching the
390    // old Django dictsort:"severity".
391    let sev_rank = |s: &str| -> u8 {
392        match s {
393            "error" => 0,
394            "warning" => 1,
395            _ => 2,
396        }
397    };
398    for items in buckets.values_mut() {
399        items.sort_by_key(|i| sev_rank(i.get("severity").and_then(|v| v.as_str()).unwrap_or("info")));
400    }
401    buckets
402        .into_iter()
403        .map(|(name, items)| json!({"type": name, "items": items}))
404        .collect()
405}
406
407fn crawl_progress(prop: &PropertyRow) -> f64 {
408    let pages = prop.last_crawl_pages_count.unwrap_or(0);
409    if pages <= 0 {
410        return 0.05; // show *some* movement once we start
411    }
412    let cap = crate::crawler::PAGE_CAP as f64;
413    ((pages as f64) / cap).min(0.9)
414}
415
416fn serialize_status(prop: &PropertyRow) -> Value {
417    let now = chrono::Utc::now().timestamp_millis();
418
419    let insights: Value = prop
420        .crawler_insights
421        .as_ref()
422        .and_then(|s| serde_json::from_str(s).ok())
423        .unwrap_or(Value::Array(Vec::new()));
424    let mut sev = serde_json::Map::new();
425    sev.insert("error".into(), 0.into());
426    sev.insert("warning".into(), 0.into());
427    sev.insert("info".into(), 0.into());
428    let mut total = 0i64;
429    if let Some(arr) = insights.as_array() {
430        for i in arr {
431            total += 1;
432            let s = i.get("severity").and_then(|v| v.as_str()).unwrap_or("info");
433            if let Some(n) = sev.get_mut(s).and_then(|v| v.as_i64()) {
434                sev.insert(s.into(), Value::from(n + 1));
435            }
436        }
437    }
438
439    let crawl_next = prop.next_run_at_crawler;
440    let lh_next = prop.next_lighthouse_run_at;
441
442    json!({
443        "crawler": {
444            "state": prop.crawl_state,
445            "started_at": ms_to_iso_opt(prop.crawl_started_at),
446            "last_attempt_at": ms_to_iso_opt(prop.last_run_at_crawler),
447            "last_success_at": ms_to_iso_opt(prop.last_crawl_success_at),
448            "last_error": prop.last_crawl_error,
449            "last_duration_ms": prop.last_crawl_duration_ms,
450            "pages_count": prop.last_crawl_pages_count,
451            "next_run_at": ms_to_iso_opt(crawl_next),
452            "is_overdue": crawl_next.map(|n| n <= now).unwrap_or(false),
453            "insights_total": total,
454            "insights_by_severity": Value::Object(sev),
455            "progress": if prop.crawl_state == "running" {
456                Value::from(crawl_progress(prop))
457            } else {
458                Value::Null
459            },
460        },
461        "lighthouse": {
462            "state": prop.lighthouse_state,
463            "started_at": ms_to_iso_opt(prop.lighthouse_started_at),
464            "last_attempt_at": ms_to_iso_opt(prop.last_lighthouse_run_at),
465            "last_success_at": ms_to_iso_opt(prop.last_lighthouse_success_at),
466            "last_error": prop.last_lighthouse_error,
467            "last_duration_ms": prop.last_lighthouse_duration_ms,
468            "next_run_at": ms_to_iso_opt(lh_next),
469            "is_overdue": lh_next.map(|n| n <= now).unwrap_or(false),
470            "scores": prop.lighthouse_scores
471                .as_ref()
472                .and_then(|s| serde_json::from_str::<Value>(s).ok())
473                .unwrap_or(Value::Null),
474        },
475        "server_time": chrono::Utc::now().to_rfc3339(),
476    })
477}
478
479pub async fn property_status(
480    State(state): State<AppState>,
481    cookies: Cookies,
482    AxumPath(id): AxumPath<Uuid>,
483) -> Response {
484    let row = match models::get_property(&state.pool, id).await {
485        Ok(Some(r)) => r,
486        _ => return not_found_json(),
487    };
488    let authed = is_authenticated(&cookies, &state);
489    if row.is_public == 0 && !authed {
490        return forbidden_json();
491    }
492    Json(serialize_status(&row)).into_response()
493}
494
495pub async fn property_recrawl(
496    State(state): State<AppState>,
497    cookies: Cookies,
498    AxumPath(id): AxumPath<Uuid>,
499) -> Response {
500    if !is_authenticated(&cookies, &state) {
501        return forbidden_json();
502    }
503    let row = match models::get_property(&state.pool, id).await {
504        Ok(Some(r)) => r,
505        _ => return not_found_json(),
506    };
507    if matches!(row.crawl_state.as_str(), "queued" | "running") {
508        return Json(json!({
509            "ok": false,
510            "reason": "already_running",
511            "crawler": serialize_status(&row).get("crawler"),
512            "lighthouse": serialize_status(&row).get("lighthouse"),
513            "server_time": chrono::Utc::now().to_rfc3339(),
514        }))
515        .into_response();
516    }
517    let now = chrono::Utc::now().timestamp_millis();
518    let _ = sqlx::query(
519        "UPDATE properties SET next_run_at_crawler = ?, last_crawl_error = NULL, updated_at = ? WHERE id = ?",
520    )
521    .bind(now)
522    .bind(now)
523    .bind(row.id.clone())
524    .execute(&state.pool)
525    .await;
526    let updated = models::get_property(&state.pool, id)
527        .await
528        .ok()
529        .flatten()
530        .unwrap_or(row);
531    let mut payload = serialize_status(&updated);
532    if let Some(obj) = payload.as_object_mut() {
533        obj.insert("ok".into(), Value::Bool(true));
534    }
535    Json(payload).into_response()
536}
537
538pub async fn property_rerun_lighthouse(
539    State(state): State<AppState>,
540    cookies: Cookies,
541    AxumPath(id): AxumPath<Uuid>,
542) -> Response {
543    if !is_authenticated(&cookies, &state) {
544        return forbidden_json();
545    }
546    let row = match models::get_property(&state.pool, id).await {
547        Ok(Some(r)) => r,
548        _ => return not_found_json(),
549    };
550    if matches!(row.lighthouse_state.as_str(), "queued" | "running") {
551        return Json(json!({
552            "ok": false,
553            "reason": "already_running",
554            "crawler": serialize_status(&row).get("crawler"),
555            "lighthouse": serialize_status(&row).get("lighthouse"),
556            "server_time": chrono::Utc::now().to_rfc3339(),
557        }))
558        .into_response();
559    }
560    let now = chrono::Utc::now().timestamp_millis();
561    let _ = sqlx::query(
562        "UPDATE properties SET next_lighthouse_run_at = ?, last_lighthouse_error = NULL, updated_at = ? WHERE id = ?",
563    )
564    .bind(now)
565    .bind(now)
566    .bind(row.id.clone())
567    .execute(&state.pool)
568    .await;
569    let updated = models::get_property(&state.pool, id)
570        .await
571        .ok()
572        .flatten()
573        .unwrap_or(row);
574    let mut payload = serialize_status(&updated);
575    if let Some(obj) = payload.as_object_mut() {
576        obj.insert("ok".into(), Value::Bool(true));
577    }
578    Json(payload).into_response()
579}