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, Path as AxumPath, Query, State},
3 http::StatusCode,
4 response::{IntoResponse, Json, Redirect, Response},
5 routing::{get, post},
6 Router,
7};
8use serde::Deserialize;
9use serde_json::{json, Value};
10use tower_cookies::Cookies;
11use uuid::Uuid;
12
13use crate::models;
14use crate::render::render;
15use crate::routes::auth::is_authenticated;
16use crate::routes::dashboard::build_property_context;
17use crate::AppState;
18
19pub fn router() -> Router<AppState> {
20 Router::new()
21 .route("/properties", get(properties).post(properties_create))
22 .route("/properties/{id}/delete", post(property_delete))
23 .route("/properties/{id}/public", post(property_public_toggle))
24}
25
26#[derive(Debug, Deserialize)]
27pub struct ListQuery {
28 pub q: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
32pub struct CreateForm {
33 pub url: String,
34}
35
36pub async fn properties(
37 State(state): State<AppState>,
38 cookies: Cookies,
39 Query(query): Query<ListQuery>,
40) -> Response {
41 if !is_authenticated(&cookies, &state) {
42 return Redirect::to("/").into_response();
43 }
44 let search = query.q.as_deref().filter(|s| !s.is_empty());
45 let rows = models::list_properties(&state.pool, search)
46 .await
47 .unwrap_or_default();
48 let mut props: Vec<Value> = Vec::with_capacity(rows.len());
49 for row in &rows {
50 match build_property_context(&state, row).await {
51 Ok(ctx) => props.push(serde_json::to_value(&ctx).unwrap_or(Value::Null)),
52 Err(e) => tracing::warn!("[properties] property context: {e:#}"),
53 }
54 }
55 let extra = minijinja::context! {
56 page => minijinja::context! {
57 title => "Properties",
58 description => "Manage your properties.",
59 },
60 title => "Properties",
61 description => "Manage your properties.",
62 properties => props,
63 q => query.q,
64 };
65 render(&state, "properties/properties.html", "/properties", true, extra)
66}
67
68pub async fn properties_create(
69 State(state): State<AppState>,
70 cookies: Cookies,
71 Form(form): Form<CreateForm>,
72) -> Response {
73 if !is_authenticated(&cookies, &state) {
74 return Redirect::to("/").into_response();
75 }
76 let url = form.url.trim();
77 // https only: the checker is HTTP/2-over-TLS only and rejects plain
78 // http:// probes, so accepting one here would create a property that can
79 // never pass a check.
80 if url.is_empty() || !url.starts_with("https://") {
81 return Redirect::to("/properties").into_response();
82 }
83 if let Err(e) = models::create_property(&state.pool, url).await {
84 tracing::warn!("[properties] create: {e:#}");
85 }
86 Redirect::to("/properties").into_response()
87}
88
89pub async fn property_delete(
90 State(state): State<AppState>,
91 cookies: Cookies,
92 AxumPath(id): AxumPath<Uuid>,
93) -> Response {
94 if !is_authenticated(&cookies, &state) {
95 return Redirect::to("/").into_response();
96 }
97 if let Err(e) = models::delete_property(&state.pool, id).await {
98 tracing::warn!("[properties] delete: {e:#}");
99 }
100 Redirect::to("/properties").into_response()
101}
102
103pub async fn property_public_toggle(
104 State(state): State<AppState>,
105 cookies: Cookies,
106 AxumPath(id): AxumPath<Uuid>,
107) -> Response {
108 if !is_authenticated(&cookies, &state) {
109 return Redirect::to("/").into_response();
110 }
111 match models::toggle_public(&state.pool, id).await {
112 Ok(is_public) => Json(json!({"success": true, "is_public": is_public})).into_response(),
113 Err(e) => {
114 tracing::warn!("[properties] toggle public: {e:#}");
115 (
116 StatusCode::INTERNAL_SERVER_ERROR,
117 Json(json!({"success": false})),
118 )
119 .into_response()
120 }
121 }
122}