338 lines
9.1 KiB
Rust
338 lines
9.1 KiB
Rust
|
#![deny(clippy::unwrap_used)]
|
||
|
|
||
|
use std::{
|
||
|
collections::{BinaryHeap, HashMap},
|
||
|
env,
|
||
|
sync::Arc,
|
||
|
};
|
||
|
|
||
|
use axum::{
|
||
|
extract::{FromRef, Multipart, Path, Query, State},
|
||
|
http::Uri,
|
||
|
response::{
|
||
|
sse::{Event, KeepAlive},
|
||
|
Html, IntoResponse, Redirect, Sse,
|
||
|
},
|
||
|
routing::get,
|
||
|
Form, Router,
|
||
|
};
|
||
|
use axum_htmx::{HxRedirect, HxRequest};
|
||
|
use axum_oidc::oidc::{self, EmptyAdditionalClaims, OidcApplication, OidcExtractor};
|
||
|
use futures_util::Stream;
|
||
|
use game::{Game, Player};
|
||
|
use garbage_collector::{start_gc, GarbageCollectorItem};
|
||
|
use rand::{distributions, Rng};
|
||
|
use sailfish::TemplateOnce;
|
||
|
use serde::{Deserialize, Serialize};
|
||
|
use stream::{PlayerBroadcastStream, ViewerBroadcastStream};
|
||
|
use tokio::sync::RwLock;
|
||
|
use tower_http::services::ServeDir;
|
||
|
|
||
|
use crate::error::Error;
|
||
|
|
||
|
type HandlerResult<T> = Result<T, Error>;
|
||
|
|
||
|
mod error;
|
||
|
mod game;
|
||
|
mod garbage_collector;
|
||
|
mod stream;
|
||
|
|
||
|
#[derive(Clone)]
|
||
|
pub struct AppState {
|
||
|
games: Arc<RwLock<HashMap<String, Game>>>,
|
||
|
game_expiry: Arc<RwLock<BinaryHeap<GarbageCollectorItem>>>,
|
||
|
oidc_application: OidcApplication<EmptyAdditionalClaims>,
|
||
|
application_base: String,
|
||
|
}
|
||
|
|
||
|
impl FromRef<AppState> for OidcApplication<EmptyAdditionalClaims> {
|
||
|
fn from_ref(input: &AppState) -> Self {
|
||
|
input.oidc_application.clone()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[tokio::main]
|
||
|
pub async fn main() {
|
||
|
dotenvy::dotenv().ok();
|
||
|
env_logger::init();
|
||
|
|
||
|
let application_base = env::var("APPLICATION_BASE").expect("APPLICATION_BASE env var");
|
||
|
let issuer = env::var("ISSUER").expect("ISSUER env var");
|
||
|
let client_id = env::var("CLIENT_ID").expect("CLIENT_ID env var");
|
||
|
let client_secret = env::var("CLIENT_SECRET").ok();
|
||
|
let scopes = env::var("SCOPES")
|
||
|
.expect("SCOPES env var")
|
||
|
.split(' ')
|
||
|
.map(|x| x.to_owned())
|
||
|
.collect::<Vec<_>>();
|
||
|
|
||
|
let oidc_application = OidcApplication::<EmptyAdditionalClaims>::create(
|
||
|
application_base
|
||
|
.parse()
|
||
|
.expect("valid APPLICATION_BASE url"),
|
||
|
issuer.to_string(),
|
||
|
client_id.to_string(),
|
||
|
client_secret.to_owned(),
|
||
|
scopes.clone(),
|
||
|
oidc::Key::generate(),
|
||
|
)
|
||
|
.await
|
||
|
.expect("Oidc Authentication Client");
|
||
|
|
||
|
let game_expiry: Arc<RwLock<BinaryHeap<GarbageCollectorItem>>> =
|
||
|
Arc::new(RwLock::new(BinaryHeap::new()));
|
||
|
let games = Arc::new(RwLock::new(HashMap::new()));
|
||
|
|
||
|
start_gc(game_expiry.clone(), games.clone());
|
||
|
|
||
|
let app_state = AppState {
|
||
|
games,
|
||
|
game_expiry,
|
||
|
oidc_application,
|
||
|
application_base,
|
||
|
};
|
||
|
|
||
|
let app = Router::new()
|
||
|
.route("/", get(handle_index).post(handle_create))
|
||
|
.route("/:id", get(handle_play).post(handle_play_submission))
|
||
|
.route("/:id/events", get(sse_play))
|
||
|
.route("/:id/view", get(handle_view).post(handle_view_next))
|
||
|
.route("/:id/view/events", get(sse_view))
|
||
|
.nest_service("/static", ServeDir::new("static"))
|
||
|
.with_state(app_state);
|
||
|
|
||
|
axum::Server::bind(&"[::]:8080".parse().expect("valid listen address"))
|
||
|
.serve(app.into_make_service())
|
||
|
.await
|
||
|
.expect("axum server");
|
||
|
}
|
||
|
|
||
|
pub async fn handle_index(
|
||
|
oidc_extractor: OidcExtractor<EmptyAdditionalClaims>,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
Ok(Html(IndexTemplate {}.render_once()?))
|
||
|
}
|
||
|
|
||
|
pub async fn handle_create(
|
||
|
State(state): State<AppState>,
|
||
|
oidc_extractor: OidcExtractor<EmptyAdditionalClaims>,
|
||
|
mut body: Multipart,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
let mut quiz: Option<Quiz> = None;
|
||
|
while let Some(field) = body.next_field().await? {
|
||
|
if field.name() == Some("quizfile") {
|
||
|
quiz = Some(toml::from_str::<Quiz>(&field.text().await?)?);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let quiz = quiz.ok_or(Error::QuizFileNotFound)?;
|
||
|
|
||
|
let game_id: String = rand::thread_rng()
|
||
|
.sample_iter(distributions::Alphanumeric)
|
||
|
.take(16)
|
||
|
.map(char::from)
|
||
|
.collect();
|
||
|
|
||
|
let game = Game::new(
|
||
|
game_id.clone(),
|
||
|
oidc_extractor.claims.subject().to_string(),
|
||
|
quiz,
|
||
|
);
|
||
|
|
||
|
let mut games = state.games.write().await;
|
||
|
|
||
|
games.insert(game_id.clone(), game);
|
||
|
|
||
|
let url = format!("{}/{}/view", state.application_base, &game_id);
|
||
|
|
||
|
let mut game_expiry = state.game_expiry.write().await;
|
||
|
game_expiry.push(GarbageCollectorItem::new_in(game_id, 24 * 3600));
|
||
|
|
||
|
Ok((HxRedirect(Uri::from_maybe_shared(url.clone())?), "Ok"))
|
||
|
}
|
||
|
|
||
|
pub async fn handle_view(
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
HxRequest(htmx): HxRequest,
|
||
|
oidc_extractor: OidcExtractor<EmptyAdditionalClaims>,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
let games = state.games.read().await;
|
||
|
let game = games.get(&id).ok_or(Error::NotFound)?;
|
||
|
|
||
|
if game.owner != oidc_extractor.claims.subject().to_string() {
|
||
|
return Err(Error::Forbidden);
|
||
|
}
|
||
|
|
||
|
Ok(Html(game.viewer_view(htmx, &state.application_base).await?))
|
||
|
}
|
||
|
|
||
|
pub async fn handle_view_next(
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
HxRequest(htmx): HxRequest,
|
||
|
oidc_extractor: OidcExtractor<EmptyAdditionalClaims>,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
let mut games = state.games.write().await;
|
||
|
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
|
||
|
|
||
|
if game.owner != oidc_extractor.claims.subject().to_string() {
|
||
|
return Err(Error::Forbidden);
|
||
|
}
|
||
|
|
||
|
game.next().await;
|
||
|
|
||
|
Ok("Ok".into_response())
|
||
|
}
|
||
|
|
||
|
pub async fn sse_view(
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
oidc_extractor: OidcExtractor<EmptyAdditionalClaims>,
|
||
|
) -> HandlerResult<Sse<impl Stream<Item = Result<Event, Error>>>> {
|
||
|
let games = state.games.read().await;
|
||
|
let game = games.get(&id).ok_or(Error::NotFound)?;
|
||
|
|
||
|
if game.owner != oidc_extractor.claims.subject().to_string() {
|
||
|
return Err(Error::Forbidden);
|
||
|
}
|
||
|
|
||
|
let rx1 = game.on_state_update.subscribe();
|
||
|
let rx2 = game.on_submission.subscribe();
|
||
|
|
||
|
let stream = ViewerBroadcastStream::new(
|
||
|
rx1,
|
||
|
rx2,
|
||
|
state.games.clone(),
|
||
|
id,
|
||
|
state.application_base.clone(),
|
||
|
);
|
||
|
|
||
|
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||
|
}
|
||
|
|
||
|
#[derive(Deserialize)]
|
||
|
pub struct PlayerQuery {
|
||
|
player: Option<String>,
|
||
|
}
|
||
|
|
||
|
pub async fn handle_play(
|
||
|
Query(query): Query<PlayerQuery>,
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
HxRequest(htmx): HxRequest,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
let mut games = state.games.write().await;
|
||
|
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
|
||
|
|
||
|
if let Some(player_id) = query.player {
|
||
|
Ok(Html(game.player_view(&player_id, htmx).await?).into_response())
|
||
|
} else {
|
||
|
let player_id: String = rand::thread_rng()
|
||
|
.sample_iter(distributions::Alphanumeric)
|
||
|
.take(32)
|
||
|
.map(char::from)
|
||
|
.collect();
|
||
|
game.players
|
||
|
.insert(player_id.to_string(), Player::default());
|
||
|
game.on_submission.send(());
|
||
|
|
||
|
Ok(Redirect::temporary(&format!(
|
||
|
"{}/{}?player={}",
|
||
|
state.application_base, id, player_id
|
||
|
))
|
||
|
.into_response())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Deserialize)]
|
||
|
pub struct SubmissionPayload {
|
||
|
selected: u32,
|
||
|
player_id: String,
|
||
|
}
|
||
|
|
||
|
pub async fn handle_play_submission(
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
Form(form): Form<SubmissionPayload>,
|
||
|
) -> HandlerResult<impl IntoResponse> {
|
||
|
let mut games = state.games.write().await;
|
||
|
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
|
||
|
|
||
|
game.handle_submission(&form.player_id, form.selected)
|
||
|
.await?;
|
||
|
|
||
|
Ok(Html(game.player_view(&form.player_id, true).await?))
|
||
|
}
|
||
|
|
||
|
#[derive(Deserialize)]
|
||
|
pub struct SsePlayerQuery {
|
||
|
player: String,
|
||
|
}
|
||
|
|
||
|
pub async fn sse_play(
|
||
|
Query(query): Query<SsePlayerQuery>,
|
||
|
Path(id): Path<String>,
|
||
|
State(state): State<AppState>,
|
||
|
) -> HandlerResult<Sse<impl Stream<Item = Result<Event, Error>>>> {
|
||
|
let games = state.games.read().await;
|
||
|
let game = games.get(&id).ok_or(Error::NotFound)?;
|
||
|
let rx = game.on_state_update.subscribe();
|
||
|
|
||
|
let stream = PlayerBroadcastStream::new(rx, state.games.clone(), id, query.player);
|
||
|
|
||
|
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||
|
}
|
||
|
|
||
|
#[derive(TemplateOnce)]
|
||
|
#[template(path = "index.stpl")]
|
||
|
struct IndexTemplate {}
|
||
|
|
||
|
#[derive(TemplateOnce)]
|
||
|
#[template(path = "play.stpl")]
|
||
|
struct PlayTemplate<'a> {
|
||
|
htmx: bool,
|
||
|
id: &'a str,
|
||
|
player_id: &'a str,
|
||
|
state: PlayerState<'a>,
|
||
|
}
|
||
|
|
||
|
#[derive(Clone)]
|
||
|
pub enum PlayerState<'a> {
|
||
|
NotStarted,
|
||
|
Answering((u32, &'a SingleChoice)),
|
||
|
Waiting(u32),
|
||
|
Result((&'a SingleChoice, Option<u32>)),
|
||
|
Completed(f32),
|
||
|
}
|
||
|
|
||
|
#[derive(TemplateOnce)]
|
||
|
#[template(path = "view.stpl")]
|
||
|
struct ViewTemplate<'a> {
|
||
|
htmx: bool,
|
||
|
id: &'a str,
|
||
|
quiz: &'a Quiz,
|
||
|
state: ViewerState<'a>,
|
||
|
}
|
||
|
|
||
|
#[derive(Clone)]
|
||
|
pub enum ViewerState<'a> {
|
||
|
NotStarted((u32, String, String)),
|
||
|
Answering((u32, &'a SingleChoice, Vec<u32>)),
|
||
|
Result((u32, &'a SingleChoice, Vec<u32>)),
|
||
|
Completed,
|
||
|
}
|
||
|
|
||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
|
pub struct Quiz {
|
||
|
pub wait_for: u64,
|
||
|
pub fields: Vec<SingleChoice>,
|
||
|
}
|
||
|
|
||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
|
pub struct SingleChoice {
|
||
|
name: String,
|
||
|
answers: Vec<String>,
|
||
|
correct: u32,
|
||
|
}
|