frontend/src/main.rs

613 lines
19 KiB
Rust

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<T, E = Error> = std::result::Result<T, E>;
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<Config, SetupError> {
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::<GlobalStyle>))
.route(
ClassIconsStyle::route(),
get(serve_asset::<ClassIconsStyle>),
)
.route(UploadScript::route(), get(serve_asset::<UploadScript>))
.route(DemoListScript::route(), get(serve_asset::<DemoListScript>))
.route(ViewerScript::route(), get(serve_asset::<ViewerScript>))
.route(ViewerStyle::route(), get(serve_asset::<ViewerStyle>))
.route(
ParseWorkerScript::route(),
get(serve_asset::<ParseWorkerScript>),
)
.route(ParserWasm::route(), get(serve_asset::<ParserWasm>))
.route(EditorScript::route(), get(serve_asset::<EditorScript>))
.route(EditorStyle::route(), get(serve_asset::<EditorStyle>))
.route(
EditWorkerScript::route(),
get(serve_asset::<EditWorkerScript>),
)
.route(EditWasm::route(), get(serve_asset::<EditWasm>))
.route(LogoPng::route(), get(serve_asset::<LogoPng>))
.route(LogoSvg::route(), get(serve_asset::<LogoSvg>))
.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::<MatchedPath>()
.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<tokio::net::unix::SocketAddr>,
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::<UdsConnectInfo>();
// 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<Incoming>| {
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<Arc<App>>,
session: SessionData,
Query(filter): Query<Filter>,
) -> Result<Markup> {
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<Arc<App>>, session: SessionData) -> Result<Markup> {
Ok(render(
AboutPage {
key: session.token(),
},
session,
))
}
#[instrument(skip(app))]
#[axum::debug_handler]
async fn api(State(app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
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<Arc<App>>,
Path(id): Path<String>,
session: SessionData,
) -> Result<Markup> {
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<Arc<App>>,
RawQuery(query): RawQuery,
return_to: Option<Path<String>>,
) -> Result<impl IntoResponse> {
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<Arc<App>>,
referrer: Option<TypedHeader<Referer>>,
) -> 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<Arc<App>>,
cookie: Option<TypedHeader<Cookie>>,
) -> 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<Arc<App>>, 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<Arc<App>>, Query(filter): Query<Filter>) -> Result<Markup> {
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<Arc<App>>,
session: SessionData,
Query(mut filter): Query<Filter>,
Path(uploader): Path<SteamId>,
) -> Result<Markup> {
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<Arc<App>>,
session: SessionData,
Query(mut filter): Query<Filter>,
Path(profile): Path<SteamId>,
) -> Result<Markup> {
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<Arc<App>>,
id: Option<Path<String>>,
session: SessionData,
) -> Result<Markup> {
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<Arc<App>>, session: SessionData) -> Result<Markup> {
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<String>) -> 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<T>(result: Result<T, Infallible>) -> T {
match result {
Ok(value) => value,
Err(err) => match err {},
}
}