mirror of
https://github.com/Steffo99/distributed-arcade.git
synced 2024-11-21 23:54:25 +00:00
Refactor code for readability
This commit is contained in:
parent
71fa8482eb
commit
0b8e571a55
16 changed files with 397 additions and 110 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -145,6 +145,7 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
|
||||||
name = "distributed_arcade"
|
name = "distributed_arcade"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"log",
|
"log",
|
||||||
|
|
|
@ -17,3 +17,4 @@ 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" }
|
regex = { version = "1.7.0" }
|
||||||
|
async-trait = { version = "0.1.58" }
|
|
@ -4,14 +4,14 @@ use std::env;
|
||||||
|
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref REDIS_CONN: String = env::var("REDIS_CONN_STRING")
|
pub(crate) static ref REDIS_CONN: String = env::var("REDIS_CONN_STRING")
|
||||||
.expect("REDIS_CONN_STRING to be set");
|
.expect("REDIS_CONN_STRING to be set");
|
||||||
|
|
||||||
pub static ref AXUM_HOST: SocketAddr = env::var("AXUM_HOST_STRING")
|
pub(crate) static ref AXUM_HOST: SocketAddr = env::var("AXUM_HOST_STRING")
|
||||||
.expect("AXUM_HOST_STRING to be set")
|
.expect("AXUM_HOST_STRING to be set")
|
||||||
.parse()
|
.parse()
|
||||||
.expect("AXUM_HOST_STRING to be a valid SocketAddr");
|
.expect("AXUM_HOST_STRING to be a valid SocketAddr");
|
||||||
|
|
||||||
pub static ref CREATE_TOKEN: String = env::var("CREATE_TOKEN")
|
pub(crate) static ref CREATE_TOKEN: String = env::var("CREATE_TOKEN")
|
||||||
.expect("CREATE_TOKEN to be set");
|
.expect("CREATE_TOKEN to be set");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
pub(crate) mod config;
|
pub(crate) mod config;
|
||||||
pub(crate) mod outcome;
|
pub(crate) mod outcome;
|
||||||
pub mod types;
|
pub mod utils;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod shortcuts;
|
||||||
|
|
||||||
|
|
||||||
use axum::routing::{get, post, put};
|
use axum::routing::{get, post, put, patch};
|
||||||
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -21,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("/", patch(routes::home::route_home_patch))
|
||||||
.route("/board/", post(routes::board::route_board_post))
|
.route("/board/", post(routes::board::route_board_post))
|
||||||
.route("/score/", put(routes::score::route_score_put))
|
.route("/score/", put(routes::score::route_score_put))
|
||||||
.layer(axum::Extension(rclient));
|
.layer(axum::Extension(rclient));
|
||||||
|
|
|
@ -1,19 +1,15 @@
|
||||||
use axum::extract::Json;
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// The `([StatusCode], Body)` tuple returned by API handlers.
|
/// The `([StatusCode], Body)` tuple returned by API handlers.
|
||||||
pub(crate) type RequestTuple = (StatusCode, Json<Value>);
|
pub(crate) type RequestTuple = (StatusCode, axum::extract::Json<Value>);
|
||||||
|
|
||||||
/// A [`Result`] made of two [`RequestTuple`]s to make handling errors easier.
|
/// A [`Result`] made of two [`RequestTuple`]s to make handling errors easier.
|
||||||
pub(crate) type RequestResult = Result<RequestTuple, RequestTuple>;
|
pub(crate) type RequestResult = Result<RequestTuple, RequestTuple>;
|
||||||
|
|
||||||
macro_rules! req_error {
|
macro_rules! req_error {
|
||||||
( $val:tt ) => {
|
( $val:tt ) => {
|
||||||
Json(serde_json::json!({
|
axum::extract::Json(serde_json::json!($val))
|
||||||
"ok": false,
|
|
||||||
"error": $val
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,23 +18,13 @@ pub(crate) use req_error;
|
||||||
|
|
||||||
macro_rules! req_success {
|
macro_rules! req_success {
|
||||||
( $val:tt ) => {
|
( $val:tt ) => {
|
||||||
Json(serde_json::json!({
|
axum::extract::Json(serde_json::json!($val))
|
||||||
"ok": true,
|
|
||||||
"data": $val
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Macro used to build a API success.
|
/// Macro used to build a API success.
|
||||||
pub(crate) use req_success;
|
pub(crate) use req_success;
|
||||||
|
|
||||||
/// The server could not connect to Redis.
|
|
||||||
pub(crate) fn redis_conn_failed() -> RequestTuple {
|
|
||||||
(
|
|
||||||
StatusCode::GATEWAY_TIMEOUT,
|
|
||||||
req_error!("Could not connect to Redis")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The execution of a command in Redis failed.
|
/// The execution of a command in Redis failed.
|
||||||
pub(crate) fn redis_cmd_failed() -> RequestTuple {
|
pub(crate) fn redis_cmd_failed() -> RequestTuple {
|
||||||
|
|
|
@ -6,7 +6,11 @@ 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;
|
use crate::shortcuts::redis::RedisConnectOr504;
|
||||||
|
use crate::shortcuts::token::Generate;
|
||||||
|
use crate::utils::sorting::SortingOrder;
|
||||||
|
use crate::utils::kebab::Skewer;
|
||||||
|
use crate::utils::token::SecureToken;
|
||||||
|
|
||||||
|
|
||||||
/// Expected input data for [`POST /board/`](route_board_post).
|
/// Expected input data for [`POST /board/`](route_board_post).
|
||||||
|
@ -18,16 +22,6 @@ pub(crate) struct RouteBoardPostInput {
|
||||||
pub(crate) order: SortingOrder,
|
pub(crate) order: SortingOrder,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expected output data for [`POST /board/`](route_board_post).
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub(crate) struct RouteBoardPostOutput {
|
|
||||||
/// The token to use to submit scores to the board.
|
|
||||||
///
|
|
||||||
/// ### 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.
|
/// 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> {
|
||||||
|
@ -41,28 +35,6 @@ async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> R
|
||||||
.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] = &[
|
|
||||||
'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].
|
||||||
|
@ -77,37 +49,34 @@ pub(crate) async fn route_board_post(
|
||||||
Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>,
|
Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>,
|
||||||
) -> outcome::RequestResult {
|
) -> outcome::RequestResult {
|
||||||
|
|
||||||
|
let name = name.to_kebab_lowercase();
|
||||||
|
|
||||||
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_connection_or_504().await?;
|
||||||
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?;
|
||||||
|
|
||||||
|
let token = SecureToken::new_or_500()?;
|
||||||
|
|
||||||
log::debug!("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!("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, Into::<&str>::into(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.0).await
|
||||||
.map_err(|_| outcome::redis_cmd_failed())?;
|
.map_err(|_| outcome::redis_cmd_failed())?;
|
||||||
|
|
||||||
log::trace!("Executing Redis transaction...");
|
log::trace!("Executing Redis transaction...");
|
||||||
|
@ -116,6 +85,6 @@ pub(crate) async fn route_board_post(
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(serde_json::to_value(RouteBoardPostOutput {token}).expect("to be able to serialize RouteBoardPostOutput"))
|
outcome::req_success!((token.0))
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,18 +2,26 @@
|
||||||
|
|
||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use crate::outcome;
|
use crate::outcome;
|
||||||
|
use crate::shortcuts::redis::RedisConnectOr504;
|
||||||
|
|
||||||
|
|
||||||
/// Handler for `GET /`.
|
/// Handler for `GET /`.
|
||||||
///
|
///
|
||||||
|
/// Verifies that the web server is working correctly.
|
||||||
|
pub(crate) async fn route_home_get() -> outcome::RequestResult {
|
||||||
|
log::trace!("Echoing back a success...");
|
||||||
|
Ok(outcome::success_null())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler for `PATCH /`.
|
||||||
|
///
|
||||||
/// Pings Redis to verify that everything is working correctly.
|
/// Pings Redis to verify that everything is working correctly.
|
||||||
pub(crate) async fn route_home_get(
|
pub(crate) async fn route_home_patch(
|
||||||
Extension(rclient): Extension<redis::Client>
|
Extension(rclient): Extension<redis::Client>
|
||||||
) -> outcome::RequestResult {
|
) -> outcome::RequestResult {
|
||||||
|
|
||||||
log::trace!("Connecting to Redis...");
|
let mut rconn = rclient.get_connection_or_504().await?;
|
||||||
let mut rconn = rclient.get_async_connection().await
|
|
||||||
.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")
|
||||||
|
|
163
src/routes/openapi.yaml
Normal file
163
src/routes/openapi.yaml
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
|
||||||
|
info:
|
||||||
|
title: "Distributed Arcade"
|
||||||
|
description: |-
|
||||||
|
A super-fast high score gatherer using Rust and Redis.
|
||||||
|
version: 0.1.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:30000"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: "Home"
|
||||||
|
description: "Miscellaneous routes"
|
||||||
|
- name: "Board"
|
||||||
|
description: "About boards"
|
||||||
|
- name: "Score"
|
||||||
|
description: "Submit scores"
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
summary: "Verify that the web server is working as expected"
|
||||||
|
tags: ["Home"]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Working as expected"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: "null"
|
||||||
|
patch:
|
||||||
|
summary: "Verify that everything is working as expected"
|
||||||
|
tags: ["Home"]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Working as expected"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: "null"
|
||||||
|
500:
|
||||||
|
description: "Did not receive `PONG` from redis"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Redis gave an unexpected response"
|
||||||
|
502:
|
||||||
|
$ref: "#/components/responses/RedisCmdFailed"
|
||||||
|
504:
|
||||||
|
$ref: "#/components/responses/RedisConnFailed"
|
||||||
|
|
||||||
|
/board/:
|
||||||
|
post:
|
||||||
|
summary: "Create a new board"
|
||||||
|
tags: ["Board"]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: "The name of the board to create."
|
||||||
|
example: "gravityfusion"
|
||||||
|
order:
|
||||||
|
type: string
|
||||||
|
example: "DSC"
|
||||||
|
description: "The ordering of the board, either ascending or descending."
|
||||||
|
enum:
|
||||||
|
- "ASC"
|
||||||
|
- "DSC"
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: "Board created successfully"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "W4SbhbJ3tnGaIM1S"
|
||||||
|
409:
|
||||||
|
description: "Board already exists"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Board already exists"
|
||||||
|
500:
|
||||||
|
description: "Could not generate secure board token"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Could not generate secure board token"
|
||||||
|
502:
|
||||||
|
$ref: "#/components/responses/RedisCmdFailed"
|
||||||
|
504:
|
||||||
|
$ref: "#/components/responses/RedisConnFailed"
|
||||||
|
|
||||||
|
/score/:
|
||||||
|
put:
|
||||||
|
summary: "Submit a score to a board"
|
||||||
|
tags: ["Score"]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
board:
|
||||||
|
type: string
|
||||||
|
description: "The board to submit the score to."
|
||||||
|
example: "gravityfusion"
|
||||||
|
score:
|
||||||
|
type: number
|
||||||
|
description: "The score to submit to the board."
|
||||||
|
example: 1234.56
|
||||||
|
player:
|
||||||
|
type: string
|
||||||
|
description: "The name of the player that the score should be submitted as."
|
||||||
|
example: "Steffo"
|
||||||
|
security:
|
||||||
|
- XBoardToken: []
|
||||||
|
responses:
|
||||||
|
401:
|
||||||
|
description: "Missing, invalid or malformed Authorization header"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Missing Authorization header"
|
||||||
|
502:
|
||||||
|
$ref: "#/components/responses/RedisCmdFailed"
|
||||||
|
504:
|
||||||
|
$ref: "#/components/responses/RedisConnFailed"
|
||||||
|
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
XBoardToken:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: "Authorization"
|
||||||
|
|
||||||
|
responses:
|
||||||
|
RedisCmdFailed:
|
||||||
|
description: "Could not execute Redis command"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Could not execute Redis command"
|
||||||
|
RedisConnFailed:
|
||||||
|
description: "Could not connect to Redis"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "Could not connect to Redis"
|
|
@ -4,11 +4,13 @@ use axum::http::StatusCode;
|
||||||
use axum::http::header::HeaderMap;
|
use axum::http::header::HeaderMap;
|
||||||
use axum::extract::{Extension, Json};
|
use axum::extract::{Extension, Json};
|
||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
use regex::Regex;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use crate::outcome;
|
use crate::outcome;
|
||||||
use crate::types::SortingOrder;
|
use crate::shortcuts::redis::RedisConnectOr504;
|
||||||
|
use crate::shortcuts::token::Authorize;
|
||||||
|
use crate::utils::kebab::Skewer;
|
||||||
|
use crate::utils::sorting::SortingOrder;
|
||||||
|
|
||||||
|
|
||||||
/// Expected input data for `PUT /score/`.
|
/// Expected input data for `PUT /score/`.
|
||||||
|
@ -40,35 +42,15 @@ pub(crate) async fn route_score_put(
|
||||||
// Redis client
|
// Redis client
|
||||||
Extension(rclient): Extension<redis::Client>,
|
Extension(rclient): Extension<redis::Client>,
|
||||||
) -> outcome::RequestResult {
|
) -> outcome::RequestResult {
|
||||||
lazy_static::lazy_static! {
|
let board = board.to_kebab_lowercase();
|
||||||
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...");
|
log::trace!("Determining the Redis key names...");
|
||||||
let order_key = format!("board:{board}:order");
|
let order_key = format!("board:{board}:order");
|
||||||
let token_key = format!("board:{board}:token");
|
let token_key = format!("board:{board}:token");
|
||||||
let scores_key = format!("board:{board}:scores");
|
let scores_key = format!("board:{board}:scores");
|
||||||
|
|
||||||
log::trace!("Connecting to Redis...");
|
let token = headers.get_authorization_or_401("X-Board-Token")?;
|
||||||
let mut rconn = rclient.get_async_connection().await
|
let mut rconn = rclient.get_connection_or_504().await?;
|
||||||
.map_err(|_| outcome::redis_conn_failed())?;
|
|
||||||
|
|
||||||
log::trace!("Checking if the token exists and matches...");
|
log::trace!("Checking if the token exists and matches...");
|
||||||
let btoken = rconn.get::<&str, String>(&token_key).await
|
let btoken = rconn.get::<&str, String>(&token_key).await
|
||||||
|
@ -84,11 +66,12 @@ pub(crate) async fn route_score_put(
|
||||||
return Err((StatusCode::FORBIDDEN, outcome::req_error!("Invalid board token")))
|
return Err((StatusCode::FORBIDDEN, outcome::req_error!("Invalid board token")))
|
||||||
}
|
}
|
||||||
|
|
||||||
log::trace!("Determining score insertion mode...");
|
log::trace!("Determining sorting order...");
|
||||||
let order = rconn.get::<&str, String>(&order_key).await
|
let order = rconn.get::<&str, String>(&order_key).await
|
||||||
.map_err(|_| outcome::redis_cmd_failed())?;
|
.map_err(|_| outcome::redis_cmd_failed())?;
|
||||||
let order = SortingOrder::try_from(order)
|
let order = SortingOrder::try_from(order.as_str())
|
||||||
.map_err(|_| outcome::redis_unexpected_behaviour())?;
|
.map_err(|_| outcome::redis_unexpected_behaviour())?;
|
||||||
|
log::trace!("Sorting order is: {order:?}");
|
||||||
|
|
||||||
log::trace!("Inserting score: {score:?}");
|
log::trace!("Inserting score: {score:?}");
|
||||||
redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg(&score).arg(&player).query_async(&mut rconn).await
|
redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg(&score).arg(&player).query_async(&mut rconn).await
|
||||||
|
@ -101,6 +84,6 @@ pub(crate) async fn route_score_put(
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(serde_json::to_value(RouteScorePutOutput {score: nscore}).expect("to be able to serialize RouteScorePutOutput"))
|
outcome::req_success!(nscore)
|
||||||
))
|
))
|
||||||
}
|
}
|
4
src/shortcuts/mod.rs
Normal file
4
src/shortcuts/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
//! Module containing utilities that **are** specific to [`distributed_arcade`].
|
||||||
|
|
||||||
|
pub(crate) mod redis;
|
||||||
|
pub(crate) mod token;
|
24
src/shortcuts/redis.rs
Normal file
24
src/shortcuts/redis.rs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use crate::outcome;
|
||||||
|
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub(crate) trait RedisConnectOr504 {
|
||||||
|
async fn get_connection_or_504(&self) -> Result<redis::aio::Connection, outcome::RequestTuple>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RedisConnectOr504 for redis::Client {
|
||||||
|
async fn get_connection_or_504(&self) -> Result<redis::aio::Connection, outcome::RequestTuple> {
|
||||||
|
log::trace!("Connecting to Redis...");
|
||||||
|
|
||||||
|
let rconn = self.get_async_connection().await
|
||||||
|
.map_err(|_|
|
||||||
|
(StatusCode::GATEWAY_TIMEOUT, outcome::req_error!("Could not connect to Redis"))
|
||||||
|
)?;
|
||||||
|
|
||||||
|
log::trace!("Connection successful!");
|
||||||
|
Ok(rconn)
|
||||||
|
}
|
||||||
|
}
|
50
src/shortcuts/token.rs
Normal file
50
src/shortcuts/token.rs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use regex::Regex;
|
||||||
|
use crate::outcome;
|
||||||
|
use crate::utils::token::SecureToken;
|
||||||
|
|
||||||
|
|
||||||
|
pub trait Authorize<'h> {
|
||||||
|
fn get_authorization_or_401(&'h self, scheme: &str) -> Result<&'h str, outcome::RequestTuple>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'h> Authorize<'h> for HeaderMap {
|
||||||
|
fn get_authorization_or_401(&'h self, scheme: &str) -> Result<&'h str, outcome::RequestTuple> {
|
||||||
|
log::trace!("Searching for the {scheme:?} Authorization header...");
|
||||||
|
|
||||||
|
log::trace!("Compiling regex...");
|
||||||
|
let auth_header_regex = Regex::new(&*format!(r#"{scheme} (\S+)"#))
|
||||||
|
.expect("scheme to create a valid regex");
|
||||||
|
|
||||||
|
log::trace!("Searching Authorization header...");
|
||||||
|
let token = self.get("Authorization")
|
||||||
|
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Missing Authorization header")))?;
|
||||||
|
|
||||||
|
log::trace!("Converting Authorization header to ASCII string...");
|
||||||
|
let token = token.to_str()
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, outcome::req_error!("Malformed Authorization header")))?;
|
||||||
|
|
||||||
|
log::trace!("Capturing the Authorization scheme value...");
|
||||||
|
let token = auth_header_regex.captures(token)
|
||||||
|
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Malformed Authorization header")))?;
|
||||||
|
|
||||||
|
log::trace!("Getting the Authorization scheme match...");
|
||||||
|
let token = token.get(1)
|
||||||
|
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Invalid Authorization header")))?;
|
||||||
|
|
||||||
|
log::trace!("Obtained Authorization scheme token!");
|
||||||
|
Ok(token.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub trait Generate where Self: Sized {
|
||||||
|
fn new_or_500() -> Result<Self, outcome::RequestTuple>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Generate for SecureToken {
|
||||||
|
fn new_or_500() -> Result<Self, outcome::RequestTuple> {
|
||||||
|
SecureToken::new()
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Could not generate token")))
|
||||||
|
}
|
||||||
|
}
|
59
src/utils/kebab.rs
Normal file
59
src/utils/kebab.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
//! Module defining and implementing the [`Skewer`] trait.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
|
||||||
|
/// Trait to skewer strings into `UPPER-KEBAB-CASE` or `lower-kebab-case`.
|
||||||
|
pub trait Skewer {
|
||||||
|
/// Replace the non-alphanumeric characters of the string with dashes.
|
||||||
|
fn to_kebab_anycase(&self) -> String;
|
||||||
|
/// Lowercase the string, then [kebabify](to_kebab_anycase) it.
|
||||||
|
fn to_kebab_lowercase(&self) -> String;
|
||||||
|
/// Uppercase the string, then [kebabify](to_kebab_anycase) it.
|
||||||
|
fn to_kebab_uppercase(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skewer for &str {
|
||||||
|
fn to_kebab_anycase(&self) -> String {
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref INVALID_CHARACTERS_REGEX: Regex = Regex::new(r#"[^A-Za-z0-9-]"#)
|
||||||
|
.expect("INVALID_CHARACTERS_REGEX to be valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
log::trace!("Kebab-ifying: {self:?}");
|
||||||
|
let kebab = INVALID_CHARACTERS_REGEX.replace_all(self, "-").into_owned();
|
||||||
|
log::trace!("Kebab-ification complete: {kebab:?}");
|
||||||
|
|
||||||
|
kebab
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_kebab_lowercase(&self) -> String {
|
||||||
|
log::trace!("Kebab-i-lower-fying: {self:?}");
|
||||||
|
let kebab = self.to_ascii_lowercase().as_str().to_kebab_anycase();
|
||||||
|
log::trace!("Kebab-i-lower-ification complete: {kebab:?}");
|
||||||
|
|
||||||
|
kebab
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_kebab_uppercase(&self) -> String {
|
||||||
|
log::trace!("Kebab-i-lower-fying: {self:?}");
|
||||||
|
let kebab = self.to_ascii_uppercase().as_str().to_kebab_anycase();
|
||||||
|
log::trace!("Kebab-i-lower-ification complete: {kebab:?}");
|
||||||
|
|
||||||
|
kebab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skewer for String {
|
||||||
|
fn to_kebab_anycase(&self) -> String {
|
||||||
|
self.as_str().to_kebab_anycase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_kebab_lowercase(&self) -> String {
|
||||||
|
self.as_str().to_kebab_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_kebab_uppercase(&self) -> String {
|
||||||
|
self.as_str().to_kebab_uppercase()
|
||||||
|
}
|
||||||
|
}
|
5
src/utils/mod.rs
Normal file
5
src/utils/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
//! Module containing utilities that aren't specific to [`distributed_arcade`].
|
||||||
|
|
||||||
|
pub mod kebab;
|
||||||
|
pub mod sorting;
|
||||||
|
pub mod token;
|
|
@ -1,5 +1,4 @@
|
||||||
//! Module containing various types recurring in multiple modules of the crate.
|
//! Module defining and implementing [`SortingOrder`].
|
||||||
|
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
@ -9,10 +8,9 @@ use serde::Deserialize;
|
||||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum SortingOrder {
|
pub enum SortingOrder {
|
||||||
/// The greater the score, the worse it is.
|
/// The greater the score, the worse it is.
|
||||||
#[serde(rename = "ASC")]
|
|
||||||
Ascending,
|
Ascending,
|
||||||
|
|
||||||
/// The greater the score, the better it is.
|
/// The greater the score, the better it is.
|
||||||
#[serde(rename = "DSC")]
|
|
||||||
Descending,
|
Descending,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,23 +25,23 @@ impl SortingOrder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the [`SortingOrder`] is stored in [Redis].
|
/// How the [`SortingOrder`] is stored in [Redis].
|
||||||
impl From<SortingOrder> for String {
|
impl From<SortingOrder> for &str {
|
||||||
fn from(ord: SortingOrder) -> Self {
|
fn from(ord: SortingOrder) -> Self {
|
||||||
match ord {
|
match ord {
|
||||||
SortingOrder::Ascending => "ASC".to_string(),
|
SortingOrder::Ascending => "Ascending",
|
||||||
SortingOrder::Descending => "DSC".to_string(),
|
SortingOrder::Descending => "Descending",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the [`SortingOrder`] is retrieved from [Redis].
|
/// How the [`SortingOrder`] is retrieved from [Redis].
|
||||||
impl TryFrom<String> for SortingOrder {
|
impl TryFrom<&str> for SortingOrder {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
fn try_from(val: String) -> Result<Self, Self::Error> {
|
fn try_from(val: &str) -> Result<Self, Self::Error> {
|
||||||
match val.as_str() {
|
match val {
|
||||||
"ASC" => Ok(Self::Ascending),
|
"Ascending" => Ok(Self::Ascending),
|
||||||
"DSC" => Ok(Self::Descending),
|
"Descending" => Ok(Self::Descending),
|
||||||
_ => Err(())
|
_ => Err(())
|
||||||
}
|
}
|
||||||
}
|
}
|
34
src/utils/token.rs
Normal file
34
src/utils/token.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
//! Module defining and implementing [`SecureToken`].
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// 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'
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A cryptographically secure, [base-62](TOKEN_CHARS) token.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SecureToken(pub String);
|
||||||
|
|
||||||
|
impl SecureToken {
|
||||||
|
pub fn new() -> Result<Self, rand::Error> {
|
||||||
|
log::trace!("Initializing secure RNG...");
|
||||||
|
let mut rng = rand::rngs::OsRng::default();
|
||||||
|
|
||||||
|
log::trace!("Generating a secure token...");
|
||||||
|
let mut token: [u32; 16] = [0; 16];
|
||||||
|
rand::Fill::try_fill(&mut token, &mut rng)?;
|
||||||
|
|
||||||
|
let token = token.iter()
|
||||||
|
.map(|e|
|
||||||
|
// Only works on platforms where usize >= 32-bit?
|
||||||
|
TOKEN_CHARS.get(*e as usize % 62)
|
||||||
|
.expect("randomly generated value to be a valid index")
|
||||||
|
)
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
Ok(Self(token))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue