use std::{sync::{Arc, Mutex}, str::FromStr}; use serde::Deserialize; use askama::Template; use axum::{ extract::State, routing::get, Router, response::Html, Form, http::StatusCode, }; use sqlx::{Connection, SqliteConnection}; use tower_http::services::ServeDir; mod config; struct AppState { conf : config::Config, db : Mutex } type SharedState = Arc>; #[tokio::main] async fn main() -> std::io::Result<()> { // simple_logger:: let mut conn = SqliteConnection::connect("sqlite:cats-radio-node.db").await.unwrap(); sqlx::migrate!() .run(&mut conn) .await .expect("could not run SQLx migrations"); let conf = config::Config::load().expect("Could not load config"); let shared_state = Arc::new(Mutex::new(AppState { conf, db: Mutex::new(conn) })); let app = Router::new() .route("/", get(dashboard)) .route("/incoming", get(incoming)) .route("/send", get(send)) .route("/settings", get(show_settings).post(post_settings)) .nest_service("/static", ServeDir::new("static")) /* requires tracing and tower, e.g. * tower = { version = "0.4", features = ["util", "timeout"] } * tower-http = { version = "0.5.0", features = ["add-extension", "trace"] } * tracing = "0.1" * tracing-subscriber = { version = "0.3", features = ["env-filter"] } .layer( ServiceBuilder::new() .layer(HandleErrorLayer::new(|error: BoxError| async move { if error.is::() { Ok(StatusCode::REQUEST_TIMEOUT) } else { Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Unhandled internal error: {error}"), )) } })) .timeout(Duration::from_secs(10)) .layer(TraceLayer::new_for_http()) .into_inner(), )*/ .with_state(shared_state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); Ok(()) } #[derive(PartialEq)] enum ActivePage { Dashboard, Incoming, Send, Settings, } #[derive(Template)] #[template(path = "dashboard.html")] struct DashboardTemplate<'a> { title: &'a str, page: ActivePage, conf: config::Config, } async fn dashboard(State(state): State) -> DashboardTemplate<'static> { DashboardTemplate { title: "Dashboard", conf: state.lock().unwrap().conf.clone(), page: ActivePage::Dashboard, } } #[derive(Template)] #[template(path = "incoming.html")] struct IncomingTemplate<'a> { title: &'a str, page: ActivePage, conf: config::Config, } async fn incoming(State(state): State) -> IncomingTemplate<'static> { IncomingTemplate { title: "Incoming", conf: state.lock().unwrap().conf.clone(), page: ActivePage::Incoming, } } #[derive(Template)] #[template(path = "send.html")] struct SendTemplate<'a> { title: &'a str, page: ActivePage, conf: config::Config, } async fn send(State(state): State) -> SendTemplate<'static> { SendTemplate { title: "Send", conf: state.lock().unwrap().conf.clone(), page: ActivePage::Send, } } #[derive(Template)] #[template(path = "settings.html")] struct SettingsTemplate<'a> { title: &'a str, page: ActivePage, conf: config::Config, } async fn show_settings(State(state): State) -> SettingsTemplate<'static> { SettingsTemplate { title: "Settings", page: ActivePage::Settings, conf: state.lock().unwrap().conf.clone(), } } #[derive(Deserialize, Debug)] struct FormConfig { callsign: String, ssid: String, icon: String, // felinet // felinet_enabled is either "on" or absent. // According to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input/checkbox // "If the value attribute was omitted, the default value for the checkbox is `on` [...]" felinet_enabled: Option, address: String, // beacon period_seconds: config::DurationSeconds, max_hops: u8, latitude: String, longitude: String, altitude: String, comment: String, antenna_height: String, antenna_gain: String, tx_power: String, // tunnel tunnel_enabled: Option, local_ip: String, netmask: String, } fn empty_string_to_none(value: &str) -> Result, T::Err> { if value == "" { Ok(None) } else { Ok(Some(value.parse()?)) } } impl TryFrom for config::Config { type Error = anyhow::Error; fn try_from(value: FormConfig) -> Result { Ok(config::Config { callsign: value.callsign, ssid: value.ssid.parse()?, icon: value.icon.parse()?, felinet: config::FelinetConfig { enabled: value.felinet_enabled.is_some(), address: value.address, }, beacon: config::BeaconConfig { period_seconds: value.period_seconds, max_hops: value.max_hops, latitude: empty_string_to_none(&value.latitude)?, longitude: empty_string_to_none(&value.longitude)?, altitude: empty_string_to_none(&value.altitude)?, comment: empty_string_to_none(&value.comment)?, antenna_height: empty_string_to_none(&value.antenna_height)?, antenna_gain: empty_string_to_none(&value.antenna_gain)?, tx_power: empty_string_to_none(&value.tx_power)?, }, tunnel: config::TunnelConfig { enabled: value.tunnel_enabled.is_some(), local_ip: value.local_ip, netmask: value.netmask, }, }) } } async fn post_settings(State(state): State, Form(input): Form) -> (StatusCode, Html) { match config::Config::try_from(input) { Ok(c) => { match c.store() { Ok(()) => { state.lock().unwrap().conf.clone_from(&c); (StatusCode::OK, Html( r#"

Configuration updated

To dashboard

"#.to_owned())) } Err(e) => { (StatusCode::INTERNAL_SERVER_ERROR, Html( format!(r#"

Internal Server Error: Could not write config

{}

"#, e))) }, } }, Err(e) => { (StatusCode::BAD_REQUEST, Html( format!(r#"

Error interpreting POST data

{}

"#, e))) }, } }