mirror of
https://github.com/Steffo99/distributed-arcade.git
synced 2024-11-21 15:44:26 +00:00
Create score submission route
This commit is contained in:
parent
edc97e9122
commit
71fa8482eb
9 changed files with 224 additions and 69 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -152,6 +152,7 @@ dependencies = [
|
|||
"r2d2",
|
||||
"rand",
|
||||
"redis",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
|
@ -16,3 +16,4 @@ serde_json = { version = "1.0.87" }
|
|||
log = { version = "0.4.17" }
|
||||
pretty_env_logger = { version = "0.4.0" }
|
||||
rand = { version = "0.8.5" }
|
||||
regex = { version = "1.7.0" }
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
mod config;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod outcome;
|
||||
pub mod types;
|
||||
mod routes;
|
||||
mod outcome;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -20,6 +22,7 @@ async fn main() {
|
|||
let webapp = axum::Router::new()
|
||||
.route("/", get(routes::home::route_home_get))
|
||||
.route("/board/", post(routes::board::route_board_post))
|
||||
.route("/score/", put(routes::score::route_score_put))
|
||||
.layer(axum::Extension(rclient));
|
||||
|
||||
log::info!("Starting Axum server...");
|
||||
|
|
|
@ -33,7 +33,7 @@ macro_rules! req_success {
|
|||
pub(crate) use req_success;
|
||||
|
||||
/// 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,
|
||||
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.
|
||||
pub(crate) fn redis_cmd_failed(_err: redis::RedisError) -> RequestTuple {
|
||||
pub(crate) fn redis_cmd_failed() -> RequestTuple {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
req_error!("Could not execute Redis command")
|
||||
|
|
|
@ -6,123 +6,116 @@ use redis::AsyncCommands;
|
|||
use serde::Serialize;
|
||||
use serde::Deserialize;
|
||||
use crate::outcome;
|
||||
use crate::types::SortingOrder;
|
||||
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
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/`.
|
||||
/// Expected input data for [`POST /board/`](route_board_post).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RouteBoardPostInput {
|
||||
pub(crate) struct RouteBoardPostInput {
|
||||
/// The name of the board to create.
|
||||
name: String,
|
||||
/// The [`Order`] of the scores in the board to create.
|
||||
order: Order,
|
||||
pub(crate) name: String,
|
||||
/// The [`SortingOrder`] of the scores in the board to create.
|
||||
pub(crate) order: SortingOrder,
|
||||
}
|
||||
|
||||
|
||||
/// Expected output data for `POST /board/`.
|
||||
/// Expected output data for [`POST /board/`](route_board_post).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RouteBoardPostOutput {
|
||||
/// The name of the created board.
|
||||
name: String,
|
||||
/// The [`Order`] of the scores in the created board.
|
||||
order: Order,
|
||||
pub(crate) struct RouteBoardPostOutput {
|
||||
/// 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> {
|
||||
log::trace!("Ensuring that the Redis key `{key}` does not contain anything...");
|
||||
|
||||
redis::cmd("TYPE").arg(&key)
|
||||
.query_async::<redis::aio::Connection, String>(rconn).await
|
||||
.map_err(outcome::redis_cmd_failed)?
|
||||
.map_err(|_| outcome::redis_cmd_failed())?
|
||||
.eq("none")
|
||||
.then_some(())
|
||||
.ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists")))
|
||||
}
|
||||
|
||||
|
||||
/// Alphabet for base-62 encoding.
|
||||
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'
|
||||
];
|
||||
|
||||
/// 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/`.
|
||||
///
|
||||
/// 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.
|
||||
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>,
|
||||
Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>,
|
||||
) -> 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...");
|
||||
let order_key = format!("board:{name}:order");
|
||||
let token_key = format!("board:{name}:token");
|
||||
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...");
|
||||
ensure_key_is_empty(&mut rconn, &order_key).await?;
|
||||
ensure_key_is_empty(&mut rconn, &token_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!("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...");
|
||||
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...");
|
||||
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...");
|
||||
rconn.set(&token_key, &token).await
|
||||
.map_err(outcome::redis_cmd_failed)?;
|
||||
.map_err(|_| outcome::redis_cmd_failed())?;
|
||||
|
||||
log::trace!("Executing Redis transaction...");
|
||||
redis::cmd("EXEC").query_async(&mut rconn).await
|
||||
.map_err(outcome::redis_cmd_failed)?;
|
||||
.map_err(|_| outcome::redis_cmd_failed())?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::to_value(RouteBoardPostOutput {name, order, token}).expect("to be able to serialize RouteBoardPostOutput"))
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::to_value(RouteBoardPostOutput {token}).expect("to be able to serialize RouteBoardPostOutput"))
|
||||
))
|
||||
}
|
||||
|
|
|
@ -7,18 +7,18 @@ use crate::outcome;
|
|||
/// Handler for `GET /`.
|
||||
///
|
||||
/// 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>
|
||||
) -> outcome::RequestResult {
|
||||
|
||||
log::trace!("Connecting to Redis...");
|
||||
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...");
|
||||
redis::cmd("PING")
|
||||
.query_async::<redis::aio::Connection, String>(&mut rconn).await
|
||||
.map_err(outcome::redis_cmd_failed)?
|
||||
.map_err(|_| outcome::redis_cmd_failed())?
|
||||
.eq("PONG")
|
||||
.then(outcome::success_null)
|
||||
.ok_or_else(outcome::redis_unexpected_behaviour)
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
pub mod home;
|
||||
pub mod board;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod board;
|
||||
pub(crate) mod score;
|
106
src/routes/score.rs
Normal file
106
src/routes/score.rs
Normal 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
50
src/types.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue