#[macro_use] extern crate rust_i18n; #[cfg(test)] #[macro_export] macro_rules! testdb { () => {{ let pool = SqlitePool::connect(":memory:").await.unwrap(); sqlx::query_file!("./migration.sql") .execute(&pool) .await .unwrap(); pool }}; } i18n!("locales", fallback = "de-AT"); use admin::station::{print::station_pdf, Station}; use auth::{AuthSession, Backend, User}; use axum::{ body::Body, extract::{FromRef, State}, response::{IntoResponse, Redirect, Response}, routing::{get, post}, Form, Router, }; use axum_login::AuthManagerLayerBuilder; use maud::{html, Markup}; use partials::page; use serde::Deserialize; use sqlx::SqlitePool; use std::{env, sync::Arc}; use tokio::net::TcpListener; use tower_sessions::{cookie::time::Duration, Expiry, Session, SessionManagerLayer}; use tower_sessions_sqlx_store_chrono::SqliteStore; pub(crate) mod admin; mod auth; pub(crate) mod models; mod partials; pub(crate) mod station; pub(crate) fn test_version() -> bool { env::var("TEST_VERSION").is_ok() } pub fn url() -> String { env::var("URL").unwrap() } #[macro_export] macro_rules! err { ($session:expr, $fmt:expr $(, $arg:expr)*) => { $session .insert( "err", &format!($fmt $(, $arg)*) ) .await .unwrap() }; } #[macro_export] macro_rules! succ { ($session:expr, $fmt:expr $(, $arg:expr)*) => { $session .insert( "succ", &format!($fmt $(, $arg)*) ) .await .unwrap() }; } #[macro_export] macro_rules! suc { ($session:expr, $message:expr) => { $session.insert("succ", &$message).await.unwrap() }; } #[macro_export] macro_rules! er { ($session:expr, $message:expr) => { $session.insert("err", &$message).await.unwrap() }; } const PICO_CSS: &str = include_str!("../assets/pico.min.css"); const MY_CSS: &str = include_str!("../assets/style.css"); const LEAFLET_CSS: &str = include_str!("../assets/leaflet.css"); const LEAFLET_JS: &str = include_str!("../assets/leaflet.js"); const MARKER_PNG: &[u8] = include_bytes!("../assets/marker-icon.png"); const LOGO_HOR: &[u8] = include_bytes!("../assets/logo-horizontal.svg"); const LOGO_VER: &[u8] = include_bytes!("../assets/logo-vertical.svg"); const LOGO_HOR_INV: &[u8] = include_bytes!("../assets/logo-horizontal-inverted.svg"); const LOGO_VER_INV: &[u8] = include_bytes!("../assets/logo-vertical-inverted.svg"); async fn serve_pico_css() -> Response { Response::builder() .header("Content-Type", "text/css") .body(Body::from(PICO_CSS)) .unwrap() } async fn serve_my_css() -> Response { Response::builder() .header("Content-Type", "text/css") .body(Body::from(MY_CSS)) .unwrap() } async fn serve_leaflet_css() -> Response { Response::builder() .header("Content-Type", "text/css") .body(Body::from(LEAFLET_CSS)) .unwrap() } async fn serve_leaflet_js() -> Response { Response::builder() .header("Content-Type", "application/javascript") .body(Body::from(LEAFLET_JS)) .unwrap() } async fn serve_marker_png() -> Response { Response::builder() .header("Content-Type", "image/png") .body(Body::from(MARKER_PNG)) .unwrap() } async fn logo_hor() -> Response { Response::builder() .header("Content-Type", "image/svg+xml") .body(Body::from(LOGO_HOR)) .unwrap() } async fn logo_ver() -> Response { Response::builder() .header("Content-Type", "image/svg+xml") .body(Body::from(LOGO_VER)) .unwrap() } async fn logo_hor_inv() -> Response { Response::builder() .header("Content-Type", "image/svg+xml") .body(Body::from(LOGO_HOR_INV)) .unwrap() } async fn logo_ver_inv() -> Response { Response::builder() .header("Content-Type", "image/svg+xml") .body(Body::from(LOGO_VER_INV)) .unwrap() } async fn redirect() -> impl IntoResponse { Redirect::to("/admin") } #[derive(Clone)] struct AppState { db: Arc, } impl FromRef for Arc { fn from_ref(state: &AppState) -> Self { state.db.clone() } } async fn set_pw( State(db): State>, session: Session, axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, ) -> Result { let Some(user) = User::find_by_id(&db, id).await else { er!(session, t!("user_id_nonexisting", id = id)); return Err(Redirect::to("/")); }; let Some(correct_code) = user.require_new_password_code else { er!( session, t!( "cant_update_pw_if_already_existing_for_user", user = user.name ) ); return Err(Redirect::to("/")); }; if correct_code != code { er!( session, t!("cant_update_pw_with_wrong_code", user = user.name) ); return Err(Redirect::to("/")); } let content = html! { h1 { (t!("new_pw_for_user", user=user.name)) } form action=(format!("/user/{}/set-pw", user.id)) method="post" { input type="hidden" name="code" value=(code); label { (t!("pw")) input type="password" name="password"; } input type="submit" value=(t!("do_login")); } }; Ok(page(content, session, false).await) } #[derive(Deserialize)] struct NewPwForm { code: String, password: String, } async fn set_concrete_pw( mut auth_session: AuthSession, State(db): State>, session: Session, axum::extract::Path(id): axum::extract::Path, Form(form): Form, ) -> impl IntoResponse { let Some(user) = User::find_by_id(&db, id).await else { er!(session, t!("user_id_nonexisting", id = id)); return Redirect::to("/").into_response(); }; let Some(correct_code) = &user.require_new_password_code else { er!( session, t!( "cant_update_pw_if_already_existing_for_user", user = user.name ) ); return Redirect::to("/").into_response(); }; if correct_code != &form.code { er!( session, t!("cant_update_pw_with_wrong_code", user = user.name) ); return Redirect::to("/").into_response(); } match user.update_pw(&db, &form.password).await { Ok(()) => { let user = User::find_by_id(&db, id).await.unwrap(); auth_session.login(&user).await.unwrap(); suc!(session, t!("pw_set")); Redirect::to("/admin").into_response() } Err(e) => { err!(session, "{e}"); Redirect::to("/admin").into_response() } } } fn router(db: SqlitePool) -> Router { let session_store = SqliteStore::new(db.clone()); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) .with_expiry(Expiry::OnInactivity(Duration::weeks(2))); let backend = Backend::new(db.clone()); let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); let state = AppState { db: Arc::new(db) }; Router::new() .route("/", get(redirect)) .nest("/s/{id}/{code}", station::routes()) // TODO: maybe switch to "/" .nest("/admin", admin::routes()) .nest("/auth", auth::routes()) .route("/user/{id}/set-pw/{code}", get(set_pw)) .route("/user/{id}/set-pw", post(set_concrete_pw)) .route("/pico.css", get(serve_pico_css)) .route("/style.css", get(serve_my_css)) .route("/leaflet.css", get(serve_leaflet_css)) .route("/leaflet.js", get(serve_leaflet_js)) .route("/marker.png", get(serve_marker_png)) .route("/logo-hor.svg", get(logo_hor)) .route("/logo-ver.svg", get(logo_ver)) .route("/logo-hor-inv.svg", get(logo_hor_inv)) .route("/logo-ver-inv.svg", get(logo_ver_inv)) .with_state(state) .layer(auth_layer) } /// Starts the main application. pub async fn start(listener: TcpListener, db: SqlitePool) { let app = router(db.clone()); tokio::spawn(async move { // Kick-off typst compilation, to reduce wait time for 1st load let stations = Station::all(&db).await; station_pdf(stations).await; }); axum::serve(listener, app).await.unwrap(); }