1
Fork 0
mirror of https://github.com/Steffo99/distributed-arcade.git synced 2024-11-24 09:04:25 +00:00

Create score submission route

This commit is contained in:
Steffo 2022-11-11 15:44:22 +00:00 committed by GitHub
parent edc97e9122
commit 71fa8482eb
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 224 additions and 69 deletions

1
Cargo.lock generated
View file

@ -152,6 +152,7 @@ dependencies = [
"r2d2", "r2d2",
"rand", "rand",
"redis", "redis",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",

View file

@ -16,3 +16,4 @@ serde_json = { version = "1.0.87" }
log = { version = "0.4.17" } log = { version = "0.4.17" }
pretty_env_logger = { version = "0.4.0" } pretty_env_logger = { version = "0.4.0" }
rand = { version = "0.8.5" } rand = { version = "0.8.5" }
regex = { version = "1.7.0" }

View file

@ -1,8 +1,10 @@
mod config; pub(crate) mod config;
pub(crate) mod outcome;
pub mod types;
mod routes; mod routes;
mod outcome;
use axum::routing::{get, post};
use axum::routing::{get, post, put};
#[tokio::main] #[tokio::main]
@ -20,6 +22,7 @@ async fn main() {
let webapp = axum::Router::new() let webapp = axum::Router::new()
.route("/", get(routes::home::route_home_get)) .route("/", get(routes::home::route_home_get))
.route("/board/", post(routes::board::route_board_post)) .route("/board/", post(routes::board::route_board_post))
.route("/score/", put(routes::score::route_score_put))
.layer(axum::Extension(rclient)); .layer(axum::Extension(rclient));
log::info!("Starting Axum server..."); log::info!("Starting Axum server...");

View file

@ -33,7 +33,7 @@ macro_rules! req_success {
pub(crate) use req_success; pub(crate) use req_success;
/// The server could not connect to Redis. /// The server could not connect to Redis.
pub(crate) fn redis_conn_failed(_err: redis::RedisError) -> RequestTuple { pub(crate) fn redis_conn_failed() -> RequestTuple {
( (
StatusCode::GATEWAY_TIMEOUT, StatusCode::GATEWAY_TIMEOUT,
req_error!("Could not connect to Redis") req_error!("Could not connect to Redis")
@ -41,7 +41,7 @@ pub(crate) fn redis_conn_failed(_err: redis::RedisError) -> RequestTuple {
} }
/// The execution of a command in Redis failed. /// The execution of a command in Redis failed.
pub(crate) fn redis_cmd_failed(_err: redis::RedisError) -> RequestTuple { pub(crate) fn redis_cmd_failed() -> RequestTuple {
( (
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
req_error!("Could not execute Redis command") req_error!("Could not execute Redis command")

View file

@ -6,123 +6,116 @@ use redis::AsyncCommands;
use serde::Serialize; use serde::Serialize;
use serde::Deserialize; use serde::Deserialize;
use crate::outcome; use crate::outcome;
use crate::types::SortingOrder;
#[derive(Copy, Clone, Debug, Serialize, Deserialize)] /// Expected input data for [`POST /board/`](route_board_post).
pub enum Order {
/// The greater the score, the worse it is.
#[serde(rename = "ASC")]
Ascending,
/// The greater the score, the better it is.
#[serde(rename = "DSC")]
Descending,
}
impl From<Order> for String {
fn from(ord: Order) -> Self {
match ord {
Order::Ascending => "ASC".to_string(),
Order::Descending => "DSC".to_string(),
}
}
}
/// Expected input data for `POST /board/`.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouteBoardPostInput { pub(crate) struct RouteBoardPostInput {
/// The name of the board to create. /// The name of the board to create.
name: String, pub(crate) name: String,
/// The [`Order`] of the scores in the board to create. /// The [`SortingOrder`] of the scores in the board to create.
order: Order, pub(crate) order: SortingOrder,
} }
/// Expected output data for [`POST /board/`](route_board_post).
/// Expected output data for `POST /board/`.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouteBoardPostOutput { pub(crate) struct RouteBoardPostOutput {
/// The name of the created board.
name: String,
/// The [`Order`] of the scores in the created board.
order: Order,
/// The token to use to submit scores to the board. /// The token to use to submit scores to the board.
token: String, ///
/// ### It's a secret
///
/// Be careful to keep this visible only to the board admins!
pub(crate) token: String,
} }
/// Ensure that there is nothing stored at a certain Redis key.
async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> Result<(), outcome::RequestTuple> { async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> Result<(), outcome::RequestTuple> {
log::trace!("Ensuring that the Redis key `{key}` does not contain anything..."); log::trace!("Ensuring that the Redis key `{key}` does not contain anything...");
redis::cmd("TYPE").arg(&key) redis::cmd("TYPE").arg(&key)
.query_async::<redis::aio::Connection, String>(rconn).await .query_async::<redis::aio::Connection, String>(rconn).await
.map_err(outcome::redis_cmd_failed)? .map_err(|_| outcome::redis_cmd_failed())?
.eq("none") .eq("none")
.then_some(()) .then_some(())
.ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists"))) .ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists")))
} }
/// Alphabet for base-62 encoding.
const TOKEN_CHARS: &[char; 62] = &[ const TOKEN_CHARS: &[char; 62] = &[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
]; ];
/// Generate a cryptographically secure Base-62 token via [`rand::rngs::OsRng`].
fn generate_secure_token() -> Result<String, outcome::RequestTuple> {
log::trace!("Generating a board token...");
let mut rng = rand::rngs::OsRng::default();
let mut token: [u32; 16] = [0; 16];
rand::Fill::try_fill(&mut token, &mut rng)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Failed to generate a secure board token.")))?;
Ok(
// FIXME: only works on platforms where usize >= 32-bit?
token.iter().map(|e| TOKEN_CHARS.get(*e as usize % 62).expect("randomly generated value to be a valid index"))
.collect::<String>()
)
}
/// Handler for `POST /board/`. /// Handler for `POST /board/`.
/// ///
/// Creates a new board, storing the details on Redis. /// Creates a new board, storing the details on [Redis].
/// ///
/// Will refuse to overwrite an already existing board. /// Will refuse to overwrite an already existing board.
pub async fn route_board_post( ///
/// Be aware that once created, boards cannot be deleted, if not manually via `redis-cli`.
///
/// If successful, returns [`StatusCode::CREATED`].
pub(crate) async fn route_board_post(
Extension(rclient): Extension<redis::Client>, Extension(rclient): Extension<redis::Client>,
Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>, Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>,
) -> outcome::RequestResult { ) -> outcome::RequestResult {
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(outcome::redis_conn_failed)?;
log::trace!("Determining the Redis key names..."); log::trace!("Determining the Redis key names...");
let order_key = format!("board:{name}:order"); let order_key = format!("board:{name}:order");
let token_key = format!("board:{name}:token"); let token_key = format!("board:{name}:token");
let scores_key = format!("board:{name}:scores"); let scores_key = format!("board:{name}:scores");
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(|_| outcome::redis_conn_failed())?;
log::trace!("Ensuring a board does not already exist..."); log::trace!("Ensuring a board does not already exist...");
ensure_key_is_empty(&mut rconn, &order_key).await?; ensure_key_is_empty(&mut rconn, &order_key).await?;
ensure_key_is_empty(&mut rconn, &token_key).await?; ensure_key_is_empty(&mut rconn, &token_key).await?;
ensure_key_is_empty(&mut rconn, &scores_key).await?; ensure_key_is_empty(&mut rconn, &scores_key).await?;
log::info!("Creating board: {name:?}"); log::debug!("Creating board: {name:?}");
let token = generate_secure_token()?;
log::trace!("Board token is: {token:?}");
log::trace!("Board order is: {order:?}"); log::trace!("Board order is: {order:?}");
log::trace!("Generating a board token...");
let mut rng = rand::rngs::OsRng::default();
let mut token: [u32; 16] = [0; 16];
rand::Fill::try_fill(&mut token, &mut rng)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Failed to generate a secure board token.")))?;
// FIXME: only works on platforms where usize >= 32-bit?
let token: String = token.iter().map(|e| TOKEN_CHARS.get(*e as usize % 62).expect("randomly generated value to be a valid index"))
.collect::<String>();
log::trace!("Board token is: {token:?}");
log::trace!("Starting Redis transaction..."); log::trace!("Starting Redis transaction...");
redis::cmd("MULTI").query_async(&mut rconn).await redis::cmd("MULTI").query_async(&mut rconn).await
.map_err(outcome::redis_cmd_failed)?; .map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Setting board order..."); log::trace!("Setting board order...");
rconn.set(&order_key, String::from(order)).await rconn.set(&order_key, String::from(order)).await
.map_err(outcome::redis_cmd_failed)?; .map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Setting board token..."); log::trace!("Setting board token...");
rconn.set(&token_key, &token).await rconn.set(&token_key, &token).await
.map_err(outcome::redis_cmd_failed)?; .map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Executing Redis transaction..."); log::trace!("Executing Redis transaction...");
redis::cmd("EXEC").query_async(&mut rconn).await redis::cmd("EXEC").query_async(&mut rconn).await
.map_err(outcome::redis_cmd_failed)?; .map_err(|_| outcome::redis_cmd_failed())?;
Ok(( Ok((
StatusCode::OK, StatusCode::CREATED,
Json(serde_json::to_value(RouteBoardPostOutput {name, order, token}).expect("to be able to serialize RouteBoardPostOutput")) Json(serde_json::to_value(RouteBoardPostOutput {token}).expect("to be able to serialize RouteBoardPostOutput"))
)) ))
} }

View file

@ -7,18 +7,18 @@ use crate::outcome;
/// Handler for `GET /`. /// Handler for `GET /`.
/// ///
/// Pings Redis to verify that everything is working correctly. /// Pings Redis to verify that everything is working correctly.
pub async fn route_home_get( pub(crate) async fn route_home_get(
Extension(rclient): Extension<redis::Client> Extension(rclient): Extension<redis::Client>
) -> outcome::RequestResult { ) -> outcome::RequestResult {
log::trace!("Connecting to Redis..."); log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await let mut rconn = rclient.get_async_connection().await
.map_err(outcome::redis_conn_failed)?; .map_err(|_| outcome::redis_conn_failed())?;
log::trace!("Sending PING and expecting PONG..."); log::trace!("Sending PING and expecting PONG...");
redis::cmd("PING") redis::cmd("PING")
.query_async::<redis::aio::Connection, String>(&mut rconn).await .query_async::<redis::aio::Connection, String>(&mut rconn).await
.map_err(outcome::redis_cmd_failed)? .map_err(|_| outcome::redis_cmd_failed())?
.eq("PONG") .eq("PONG")
.then(outcome::success_null) .then(outcome::success_null)
.ok_or_else(outcome::redis_unexpected_behaviour) .ok_or_else(outcome::redis_unexpected_behaviour)

View file

@ -1,2 +1,3 @@
pub mod home; pub(crate) mod home;
pub mod board; pub(crate) mod board;
pub(crate) mod score;

106
src/routes/score.rs Normal file
View file

@ -0,0 +1,106 @@
//! Module defining routes for `/score/`.
use axum::http::StatusCode;
use axum::http::header::HeaderMap;
use axum::extract::{Extension, Json};
use redis::AsyncCommands;
use regex::Regex;
use serde::Serialize;
use serde::Deserialize;
use crate::outcome;
use crate::types::SortingOrder;
/// Expected input data for `PUT /score/`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct RouteScorePutInput {
/// The board to submit the score to.
pub board: String,
/// The score to submit.
pub score: f64,
/// The name of the player submitting the score.
pub player: String,
}
/// Expected output data for `PUT /score/`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct RouteScorePutOutput {
/// The best score of the player.
score: f64,
}
/// Handler for `PUT /score/`.
pub(crate) async fn route_score_put(
// Request headers
headers: HeaderMap,
// Request body
Json(RouteScorePutInput {board, score, player}): Json<RouteScorePutInput>,
// Redis client
Extension(rclient): Extension<redis::Client>,
) -> outcome::RequestResult {
lazy_static::lazy_static! {
static ref AUTH_HEADER_REGEX: Regex = Regex::new(r#"X-Board (\S+)"#)
.expect("AUTH_HEADER_REGEX to be valid");
}
log::trace!("Checking the Authorization header...");
let token = headers.get("Authorization")
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Missing Authorization header")))?;
let token = token.to_str()
.map_err(|_| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?;
let token = AUTH_HEADER_REGEX.captures(token)
.ok_or_else(|| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?;
let token = token.get(1)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Invalid Authorization header")))?;
let token = token.as_str();
log::trace!("Received token: {token:?}");
log::trace!("Determining the Redis key names...");
let order_key = format!("board:{board}:order");
let token_key = format!("board:{board}:token");
let scores_key = format!("board:{board}:scores");
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(|_| outcome::redis_conn_failed())?;
log::trace!("Checking if the token exists and matches...");
let btoken = rconn.get::<&str, String>(&token_key).await
.map_err(|_| outcome::redis_cmd_failed())?;
if btoken.is_empty() {
log::trace!("Token is not set, board does not exist...");
return Err((StatusCode::NOT_FOUND, outcome::req_error!("No such board")))
}
if btoken != token {
log::trace!("Token does not match, forbidding...");
return Err((StatusCode::FORBIDDEN, outcome::req_error!("Invalid board token")))
}
log::trace!("Determining score insertion mode...");
let order = rconn.get::<&str, String>(&order_key).await
.map_err(|_| outcome::redis_cmd_failed())?;
let order = SortingOrder::try_from(order)
.map_err(|_| outcome::redis_unexpected_behaviour())?;
log::trace!("Inserting score: {score:?}");
redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg(&score).arg(&player).query_async(&mut rconn).await
.map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Getting the new score...");
let nscore = rconn.zscore::<&str, &str, f64>(&scores_key, &player).await
.map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Received score: {nscore:?}");
Ok((
StatusCode::OK,
Json(serde_json::to_value(RouteScorePutOutput {score: nscore}).expect("to be able to serialize RouteScorePutOutput"))
))
}

50
src/types.rs Normal file
View file

@ -0,0 +1,50 @@
//! Module containing various types recurring in multiple modules of the crate.
use serde::Serialize;
use serde::Deserialize;
/// A sorting order for scores.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum SortingOrder {
/// The greater the score, the worse it is.
#[serde(rename = "ASC")]
Ascending,
/// The greater the score, the better it is.
#[serde(rename = "DSC")]
Descending,
}
impl SortingOrder {
/// Get the mode to use when using the [Redis] command [`ZADD`](https://redis.io/commands/zadd/).
pub fn zadd_mode(&self) -> String {
match self {
Self::Ascending => "LT".to_string(),
Self::Descending => "GT".to_string(),
}
}
}
/// How the [`SortingOrder`] is stored in [Redis].
impl From<SortingOrder> for String {
fn from(ord: SortingOrder) -> Self {
match ord {
SortingOrder::Ascending => "ASC".to_string(),
SortingOrder::Descending => "DSC".to_string(),
}
}
}
/// How the [`SortingOrder`] is retrieved from [Redis].
impl TryFrom<String> for SortingOrder {
type Error = ();
fn try_from(val: String) -> Result<Self, Self::Error> {
match val.as_str() {
"ASC" => Ok(Self::Ascending),
"DSC" => Ok(Self::Descending),
_ => Err(())
}
}
}