mod asset; mod config; mod data; mod error; mod fragments; mod pages; mod session; use crate::asset::{guess_mime, serve_asset}; pub use crate::config::Config; use crate::config::Listen; use crate::data::demo::{Demo, Filter, ListDemo}; use crate::data::maps::{map_list, MapList}; use crate::data::steam_id::SteamId; use crate::data::user::User; use crate::error::SetupError; use crate::fragments::demo_list::DemoList; use crate::pages::about::AboutPage; use crate::pages::api::ApiPage; use crate::pages::demo::{ClassIconsStyle, DemoPage}; use crate::pages::edit::{EditWasm, EditWorkerScript, EditorPage, EditorScript, EditorStyle}; use crate::pages::index::{DemoListScript, Index}; use crate::pages::profile::Profile; use crate::pages::upload::{UploadPage, UploadScript}; use crate::pages::uploads::Uploads; use crate::pages::viewer::{ParseWorkerScript, ParserWasm, ViewerPage, ViewerScript, ViewerStyle}; use crate::pages::{render, GlobalStyle}; use crate::session::{SessionData, COOKIE_NAME}; use async_session::{MemoryStore, Session, SessionStore}; use axum::extract::{connect_info, MatchedPath, Path, Query, RawQuery}; use axum::http::header::{CONTENT_TYPE, ETAG, LOCATION, SET_COOKIE}; use axum::http::{HeaderValue, Request, StatusCode}; use axum::response::IntoResponse; use axum::{extract::State, routing::get, serve, Router}; use axum_extra::headers::{Cookie, Referer}; use axum_extra::TypedHeader; use demostf_build::Asset; pub use error::Error; use hyper::body::Incoming; use hyper::header::CACHE_CONTROL; use hyper_util::{ rt::{TokioExecutor, TokioIo}, server, }; use include_dir::{include_dir, Dir}; use maud::{Markup, Render}; use opentelemetry::trace::TracerProvider; use opentelemetry::KeyValue; use opentelemetry_otlp::{SpanExporter, WithExportConfig, WithTonicConfig}; use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::Resource; use reqwest::Url; use secretfile::load; use sqlx::PgPool; use std::convert::Infallible; use std::env::{args, var}; use std::fs::{remove_file, set_permissions, Permissions}; use std::net::SocketAddr; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use steam_openid::SteamOpenId; use tokio::net::unix::UCred; use tokio::net::{UnixListener, UnixStream}; use tokio::select; use tokio::signal::ctrl_c; use tonic::codegen::Service; use tonic::transport::{ClientTlsConfig, Identity}; use tower_http::trace::TraceLayer; use tracing::{error, info, info_span, instrument}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; use urlencoding::Encoded; pub type Result = std::result::Result; struct App { connection: PgPool, openid: SteamOpenId, api: String, maps: String, sync: String, map_list: MapList, url: String, pub session_store: MemoryStore, } #[derive(Asset)] #[asset(source = "images/logo.png", url = "/images/logo.png")] struct LogoPng; #[derive(Asset)] #[asset(source = "images/logo.svg", url = "/images/logo.svg")] struct LogoSvg; static KILL_ICONS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/kill_icons"); fn setup() -> Result { let config = args() .nth(1) .as_deref() .map(Config::load) .transpose()? .or_else(Config::env) .ok_or(SetupError::NoConfigProvided)?; let open_telemetry = if let Some(tracing_cfg) = config .tracing .as_ref() .filter(|tracing_cfg| !tracing_cfg.endpoint.is_empty()) { let mut tls_config = ClientTlsConfig::new().with_webpki_roots(); let mut otlp_exporter = SpanExporter::builder() .with_tonic() .with_endpoint(&tracing_cfg.endpoint); if let Some(tracing_ident) = tracing_cfg.tls.as_ref().map(|tracing_tls_cfg| { let key = load(&tracing_tls_cfg.key_file)?; let cert = load(&tracing_tls_cfg.cert_file)?; Result::<_, SetupError>::Ok(Identity::from_pem(cert, key)) }) { tls_config = tls_config.identity(tracing_ident?); } otlp_exporter = otlp_exporter.with_tls_config(tls_config); let tracer = SdkTracerProvider::builder() .with_resource( Resource::builder() .with_attribute(KeyValue::new("service.name", "demos.tf")) .build(), ) .with_batch_exporter(otlp_exporter.build()?) .build() .tracer("demos.tf"); Some(tracing_opentelemetry::layer().with_tracer(tracer)) } else { None }; tracing_subscriber::registry() .with(EnvFilter::new(var("RUST_LOG").unwrap_or_else(|_| { "demostf_frontend=debug,tower_http=debug,sqlx=debug".into() }))) .with(open_telemetry) .with(tracing_subscriber::fmt::layer().with_filter(EnvFilter::new( var("RUST_LOG").unwrap_or_else(|_| "warn,demostf_frontend=info".into()), ))) .try_init()?; Ok(config) } #[tokio::main] async fn main() -> Result<()> { let config = setup()?; let connection = config.database.connect().await?; let map_list = map_list(&connection).await?; let session_store = MemoryStore::new(); let state = Arc::new(App { connection, openid: SteamOpenId::new(&config.site.url, "/login/callback") .expect("invalid steam login url"), api: config.site.api, maps: config.site.maps, sync: config.site.sync, url: config.site.url, map_list, session_store: session_store.clone(), }); let app = Router::new() .route("/", get(index)) .route("/uploads/{uploader}", get(uploads)) .route("/profiles/{uploader}", get(profiles)) .route(GlobalStyle::route(), get(serve_asset::)) .route( ClassIconsStyle::route(), get(serve_asset::), ) .route(UploadScript::route(), get(serve_asset::)) .route(DemoListScript::route(), get(serve_asset::)) .route(ViewerScript::route(), get(serve_asset::)) .route(ViewerStyle::route(), get(serve_asset::)) .route( ParseWorkerScript::route(), get(serve_asset::), ) .route(ParserWasm::route(), get(serve_asset::)) .route(EditorScript::route(), get(serve_asset::)) .route(EditorStyle::route(), get(serve_asset::)) .route( EditWorkerScript::route(), get(serve_asset::), ) .route(EditWasm::route(), get(serve_asset::)) .route(LogoPng::route(), get(serve_asset::)) .route(LogoSvg::route(), get(serve_asset::)) .route("/fragments/demo-list", get(demo_list)) .route("/about", get(about)) .route("/api", get(api)) .route("/login/callback", get(login_callback)) .route("/login/callback/{return_to}", get(login_callback)) .route("/login", get(login)) .route("/logout", get(logout)) .route("/upload", get(upload)) .route("/viewer", get(viewer)) .route("/edit", get(edit)) .route("/viewer/{id}", get(viewer)) .route("/{id}", get(demo)) .route("/images/kill_icons/{icon}", get(kill_icons)) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request .extensions() .get::() .map(MatchedPath::as_str); info_span!( "http_request", method = ?request.method(), matched_path, some_other_field = tracing::field::Empty, ) }), ) .fallback(handler_404) .with_state(state); match config.listen { Listen::Tcp { address, port } => { let service = app.into_make_service(); let addr = SocketAddr::from((address, port)); let listener = tokio::net::TcpListener::bind(addr).await?; info!("listening on http://{}", listener.local_addr()?); let ctrl_c = async { ctrl_c().await.expect("failed to install Ctrl+C handler"); }; serve(listener, service) .with_graceful_shutdown(ctrl_c) .await?; } Listen::Socket { path } => { info!("listening on {}", path.display()); if path.exists() { remove_file(&path)?; } let uds = UnixListener::bind(path.clone())?; set_permissions(&path, Permissions::from_mode(0o666))?; #[derive(Clone, Debug)] #[allow(dead_code)] struct UdsConnectInfo { peer_addr: Arc, peer_cred: UCred, } impl connect_info::Connected<&UnixStream> for UdsConnectInfo { fn connect_info(target: &UnixStream) -> Self { let peer_addr = target.peer_addr().unwrap(); let peer_cred = target.peer_cred().unwrap(); Self { peer_addr: Arc::new(peer_addr), peer_cred, } } } let mut make_service = app.into_make_service_with_connect_info::(); // See https://github.com/tokio-rs/axum/blob/main/examples/serve-with-hyper/src/main.rs for // more details about this setup loop { let (socket, _remote_addr) = select! { result = uds.accept() => { result? }, _ = ctrl_c() => { break; } }; let tower_service = unwrap_infallible(make_service.call(&socket).await); tokio::spawn(async move { let socket = TokioIo::new(socket); let hyper_service = hyper::service::service_fn(move |request: Request| { tower_service.clone().call(request) }); if let Err(err) = server::conn::auto::Builder::new(TokioExecutor::new()) .serve_connection_with_upgrades(socket, hyper_service) .await { eprintln!("failed to serve connection: {err:#}"); } }); } } } Ok(()) } #[instrument(skip(app))] #[axum::debug_handler] async fn index( State(app): State>, session: SessionData, Query(filter): Query, ) -> Result { let demos = ListDemo::list(&app.connection, filter).await?; Ok(render( Index { demos: DemoList::new(&demos), maps: &app.map_list, api: &app.api, }, session, )) } #[instrument(skip(_app))] #[axum::debug_handler] async fn about(State(_app): State>, session: SessionData) -> Result { Ok(render( AboutPage { key: session.token(), }, session, )) } #[instrument(skip(app))] #[axum::debug_handler] async fn api(State(app): State>, session: SessionData) -> Result { Ok(render( ApiPage { steam_id: session.steam_id().unwrap_or(SteamId::Id(76561198024494988)), api_base: &app.api, }, session, )) } #[instrument(skip(app))] #[axum::debug_handler] async fn demo( State(app): State>, Path(id): Path, session: SessionData, ) -> Result { let id = id.parse().map_err(|_| Error::NotFound)?; let demo = Demo::by_id(&app.connection, id) .await? .ok_or(Error::NotFound)?; Ok(render(DemoPage { demo }, session)) } #[axum::debug_handler] async fn login_callback( State(app): State>, RawQuery(query): RawQuery, return_to: Option>, ) -> Result { let referrer_openid = return_to .as_ref() .map(|return_to| format!("/login/callback/{}", Encoded(return_to.as_str()))) .map(|callback| SteamOpenId::new(&app.url, &callback).ok()) .flatten(); let openid = referrer_openid.as_ref().unwrap_or(&app.openid); let query = query.as_deref().unwrap_or_default(); let steam_id = openid.verify(query).await.map_err(|e| { error!("{e:?}"); Error::SteamAuth })?; info!(steam_id, "received steam login callback"); let steam_id = SteamId::new(steam_id); let user = User::get(&app.connection, steam_id).await?; let mut session = Session::new(); session .insert("user", user) .expect("failed to serialize user"); let cookie = app .session_store .store_session(session) .await? .unwrap_or_default(); let return_to = return_to .as_ref() .map(|Path(return_to)| return_to) .map(|return_to| HeaderValue::from_str(&return_to).ok()) .flatten() .unwrap_or(HeaderValue::from_static("/")); Ok(( StatusCode::FOUND, [ ( SET_COOKIE, HeaderValue::from_str(&format!( "{}={}; HttpOnly; SameSite=Lax; Path=/", COOKIE_NAME, cookie )) .expect("invalid cookie"), ), (LOCATION, return_to), ], )) } #[instrument(skip(app))] #[axum::debug_handler] async fn login( State(app): State>, referrer: Option>, ) -> impl IntoResponse { let referrer = referrer .map(|TypedHeader(referrer)| referrer.to_string()) .map(|referrer| Url::parse(&referrer).ok()) .flatten(); let referrer_openid = referrer .as_ref() .map(|referrer| format!("/login/callback/{}", Encoded(referrer.path()))) .map(|callback| SteamOpenId::new(&app.url, &callback).ok()) .flatten(); let openid = referrer_openid.as_ref().unwrap_or(&app.openid); ( StatusCode::FOUND, [( LOCATION, HeaderValue::from_str(openid.get_redirect_url()).unwrap(), )], "", ) } #[instrument(skip(app, cookie))] #[axum::debug_handler] async fn logout( State(app): State>, cookie: Option>, ) -> impl IntoResponse { if let Some(session_cookie) = cookie.as_deref().and_then(|cookie| cookie.get(COOKIE_NAME)) { if let Ok(Some(cookie)) = app.session_store.load_session(session_cookie.into()).await { let _ = app.session_store.destroy_session(cookie).await; } } ( StatusCode::FOUND, [ ( SET_COOKIE, HeaderValue::from_str(&format!( "{}=; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT", COOKIE_NAME )) .expect("invalid cookie"), ), (LOCATION, HeaderValue::from_str("/").unwrap()), ], ) } #[instrument(skip(app))] #[axum::debug_handler] async fn upload(State(app): State>, session: SessionData) -> impl IntoResponse { if let Some(token) = session.token() { render( UploadPage { key: &token, api: app.api.as_str(), }, session, ) .into_response() } else { ( StatusCode::FOUND, [(LOCATION, HeaderValue::from_str("/").unwrap())], ) .into_response() } } #[instrument(skip(app))] #[axum::debug_handler] async fn demo_list(State(app): State>, Query(filter): Query) -> Result { let demos = ListDemo::list(&app.connection, filter).await?; Ok(DemoList { demos: &demos }.render()) } #[instrument(skip(app))] #[axum::debug_handler] async fn uploads( State(app): State>, session: SessionData, Query(mut filter): Query, Path(uploader): Path, ) -> Result { filter.uploader = Some(uploader.clone()); let demos = ListDemo::list(&app.connection, filter).await?; let user = User::get(&app.connection, uploader) .await .map_err(|_| Error::NotFound)?; Ok(render( Uploads { user, demos: DemoList::new(&demos), maps: &app.map_list, api: &app.api, }, session, )) } #[instrument(skip(app))] #[axum::debug_handler] async fn profiles( State(app): State>, session: SessionData, Query(mut filter): Query, Path(profile): Path, ) -> Result { filter.players.push(profile.clone()); let demos = ListDemo::list(&app.connection, filter).await?; let user = User::get(&app.connection, profile) .await .map_err(|_| Error::NotFound)?; Ok(render( Profile { user, demos: DemoList::new(&demos), maps: &app.map_list, api: &app.api, }, session, )) } #[instrument(skip(app))] #[axum::debug_handler] async fn viewer( State(app): State>, id: Option>, session: SessionData, ) -> Result { let demo = if let Some(Path(id)) = id { let id = id.parse().map_err(|_| Error::NotFound)?; Some( Demo::by_id(&app.connection, id) .await? .ok_or(Error::NotFound)?, ) } else { None }; Ok(render( ViewerPage { demo, maps: &app.maps, sync: &app.sync, }, session, )) } #[axum::debug_handler] async fn edit(State(_app): State>, session: SessionData) -> Result { Ok(render(EditorPage, session)) } #[axum::debug_handler] async fn handler_404() -> impl IntoResponse { Error::NotFound } #[axum::debug_handler] pub async fn kill_icons(path: Path) -> impl IntoResponse { let path = path.as_str(); match KILL_ICONS.get_file(path) { Some(file) => ( [ ( CONTENT_TYPE, HeaderValue::from_str(guess_mime(path)).unwrap(), ), (ETAG, HeaderValue::from_static("theseshouldbefullystatic")), ( CACHE_CONTROL, HeaderValue::from_static("public, max-age=2592000, immutable"), ), ], file.contents(), ) .into_response(), None => StatusCode::NOT_FOUND.into_response(), } } fn unwrap_infallible(result: Result) -> T { match result { Ok(value) => value, Err(err) => match err {}, } }