add code 2
This commit is contained in:
+178
-5
@@ -1,6 +1,179 @@
|
||||
pub const ID: &str = "code1";
|
||||
pub const CODE: &str = "Qj4eAn";
|
||||
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted.";
|
||||
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later.";
|
||||
use axum::{extract::ConnectInfo, http::StatusCode, Json};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub(super) fn on_submit(_live: bool) {}
|
||||
pub const ID: &str = "code1";
|
||||
const SELF_NOTIFICATION: &str = "Code 1 has been submitted while the server is live.";
|
||||
const CHALLENGE_1_FIRST: &str = "TODO: secded data";
|
||||
const CHALLENGE_1_SECOND: &str = "SECDED-13";
|
||||
|
||||
pub async fn buzz_event_handler(
|
||||
ConnectInfo(address): ConnectInfo<SocketAddr>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let delta_ms = payload
|
||||
.get("delta_ms")
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"delta_ms must be a signed 64-bit integer".to_string(),
|
||||
)
|
||||
})?;
|
||||
let signature = payload
|
||||
.get("signature")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"signature must be a hexadecimal HMAC-SHA256 string".to_string(),
|
||||
)
|
||||
})?;
|
||||
let signature = decode_hex_signature(signature).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid buzz event signature".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(crate::secrets::runtime().code1_hmac_secret.as_bytes())
|
||||
.expect("HMAC accepts secrets of any length");
|
||||
mac.update(delta_ms.to_string().as_bytes());
|
||||
mac.verify_slice(&signature).map_err(|_| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid buzz event signature".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let live = crate::manage::server_live_state();
|
||||
crate::codes::history::record_successful_submission(address.ip(), live, ID)
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
on_submit(live, delta_ms).await.map_err(|error| {
|
||||
leptos::logging::log!("Code 1 buzz event failed: {error}");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Unable to process buzz event".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
fn decode_hex_signature(signature: &str) -> Option<Vec<u8>> {
|
||||
if signature.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
signature
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.map(|pair| {
|
||||
let high = hex_nibble(pair[0])?;
|
||||
let low = hex_nibble(pair[1])?;
|
||||
Some((high << 4) | low)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex_nibble(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_submit(live: bool, delta_ms: i64) -> Result<(), String> {
|
||||
let direction = match delta_ms.cmp(&0) {
|
||||
std::cmp::Ordering::Less => "short",
|
||||
std::cmp::Ordering::Greater => "long",
|
||||
std::cmp::Ordering::Equal => "exactly on time",
|
||||
};
|
||||
let congrats_message = format!(
|
||||
"Congratulations on completing Challenge 1. It appears that you've figured out what to do, although your buzz was {}ms {}. Consider being more precise in the future. Your sloppiness has been noted.",
|
||||
delta_ms.abs(), direction,
|
||||
);
|
||||
|
||||
if !live {
|
||||
leptos::logging::log!(
|
||||
"Code 1 SMS plan (not sent; server is offline): self notification: {SELF_NOTIFICATION}; congratulations to recipients 1 and 2: {congrats_message}; recipient 1: {CHALLENGE_1_FIRST}; recipient 2: {CHALLENGE_1_SECOND}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let secrets = crate::secrets::runtime();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
send_textbee_sms(
|
||||
&client,
|
||||
&secrets.textbee_api_key,
|
||||
secrets.textbee_device_id.as_deref(),
|
||||
&[&secrets.recipient_self],
|
||||
SELF_NOTIFICATION,
|
||||
)
|
||||
.await?;
|
||||
send_textbee_sms(
|
||||
&client,
|
||||
&secrets.textbee_api_key,
|
||||
secrets.textbee_device_id.as_deref(),
|
||||
&[&secrets.recipient_1, &secrets.recipient_2],
|
||||
&congrats_message,
|
||||
)
|
||||
.await?;
|
||||
send_textbee_sms(
|
||||
&client,
|
||||
&secrets.textbee_api_key,
|
||||
secrets.textbee_device_id.as_deref(),
|
||||
&[&secrets.recipient_1],
|
||||
CHALLENGE_1_FIRST,
|
||||
)
|
||||
.await?;
|
||||
send_textbee_sms(
|
||||
&client,
|
||||
&secrets.textbee_api_key,
|
||||
secrets.textbee_device_id.as_deref(),
|
||||
&[&secrets.recipient_2],
|
||||
CHALLENGE_1_SECOND,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_textbee_sms(
|
||||
client: &reqwest::Client,
|
||||
api_key: &str,
|
||||
device_id: Option<&str>,
|
||||
recipients: &[&str],
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut payload = serde_json::json!({
|
||||
"message": message,
|
||||
"recipients": recipients,
|
||||
});
|
||||
if let Some(device_id) = device_id {
|
||||
payload["deviceId"] = serde_json::Value::String(device_id.to_string());
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post("https://api.textbee.dev/api/v1/gateway/send-sms")
|
||||
.header("x-api-key", api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("TextBee request failed: {error}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let response_body = response.text().await.map_err(|error| {
|
||||
format!("TextBee returned HTTP {status}; unable to read error: {error}")
|
||||
})?;
|
||||
return Err(format!("TextBee returned HTTP {status}: {response_body}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
pub const ID: &str = "code2";
|
||||
pub const CODE: &str = "Qj4eAn";
|
||||
pub const NEXT_STEP_TEXT: &str = "TODO: replace this with the next step.";
|
||||
|
||||
const SELF_NOTIFICATION: &str = "Code 2 has been submitted.";
|
||||
|
||||
pub(super) async fn on_submit(live: bool) -> Result<(), String> {
|
||||
if !live {
|
||||
leptos::logging::log!("Code 2 SMS (not sent; server is offline): {SELF_NOTIFICATION}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let secrets = crate::secrets::runtime();
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"message": SELF_NOTIFICATION,
|
||||
"recipients": [&secrets.recipient_self],
|
||||
});
|
||||
if let Some(device_id) = &secrets.textbee_device_id {
|
||||
payload["deviceId"] = serde_json::Value::String(device_id.clone());
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post("https://api.textbee.dev/api/v1/gateway/send-sms")
|
||||
.header("x-api-key", &secrets.textbee_api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("TextBee request failed: {error}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let response_body = response.text().await.map_err(|error| {
|
||||
format!("TextBee returned HTTP {status}; unable to read error: {error}")
|
||||
})?;
|
||||
return Err(format!("TextBee returned HTTP {status}: {response_body}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ type HistoryViewEntry = (String, String, bool, String, String);
|
||||
async fn get_submission_history(
|
||||
secret_key: String,
|
||||
) -> Result<Vec<HistoryViewEntry>, ServerFnError> {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key)? {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
||||
return Err(ServerFnError::new("Invalid secret key"));
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnE
|
||||
|
||||
#[server]
|
||||
async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key)? {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
||||
return Err(ServerFnError::new("Invalid secret key"));
|
||||
}
|
||||
|
||||
|
||||
+14
-18
@@ -6,31 +6,27 @@ use leptos_router::{
|
||||
};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod code1;
|
||||
pub mod code1;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod code2;
|
||||
|
||||
pub mod history;
|
||||
|
||||
#[server]
|
||||
async fn submit_code(code: String) -> Result<String, ServerFnError> {
|
||||
let message = if code == crate::codes::code1::CODE {
|
||||
let live = crate::manage::server_live_state();
|
||||
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
||||
leptos_axum::extract().await?;
|
||||
if code != crate::codes::code2::CODE {
|
||||
return Ok("This code is not recognized.".to_string());
|
||||
}
|
||||
|
||||
history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?;
|
||||
let live = crate::manage::server_live_state();
|
||||
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
||||
leptos_axum::extract().await?;
|
||||
history::record_successful_submission(address.ip(), live, crate::codes::code2::ID)?;
|
||||
crate::codes::code2::on_submit(live)
|
||||
.await
|
||||
.map_err(ServerFnError::new)?;
|
||||
|
||||
crate::codes::code1::on_submit(live);
|
||||
|
||||
if live {
|
||||
crate::codes::code1::LIVE_MESSAGE
|
||||
} else {
|
||||
crate::codes::code1::NOT_LIVE_MESSAGE
|
||||
}
|
||||
} else {
|
||||
"This code is not recognized."
|
||||
};
|
||||
|
||||
Ok(message.to_string())
|
||||
Ok(crate::codes::code2::NEXT_STEP_TEXT.to_string())
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
|
||||
+5
-1
@@ -7,7 +7,7 @@ async fn main() {
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
|
||||
code_submission::secrets::load_dotenv().expect("Unable to load .env configuration");
|
||||
code_submission::secrets::init().expect("Unable to load .env configuration");
|
||||
|
||||
let conf = get_configuration(None).unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
@@ -16,6 +16,10 @@ async fn main() {
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/event/buzz",
|
||||
axum::routing::post(code_submission::codes::code1::buzz_event_handler),
|
||||
)
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ async fn get_live_state() -> Result<bool, ServerFnError> {
|
||||
|
||||
#[server]
|
||||
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key)? {
|
||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
||||
return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
|
||||
}
|
||||
|
||||
|
||||
+93
-13
@@ -1,22 +1,102 @@
|
||||
use leptos::prelude::ServerFnError;
|
||||
use std::io::ErrorKind;
|
||||
use std::{env, io::ErrorKind, sync::OnceLock};
|
||||
|
||||
pub(crate) const DEV_SECRET_KEY: &str = "DEV_SECRET_KEY";
|
||||
pub(crate) const TEXTBEE_API_KEY: &str = "TEXTBEE_API_KEY";
|
||||
pub(crate) const TEXTBEE_DEVICE_ID: &str = "TEXTBEE_DEVICE_ID";
|
||||
pub(crate) const RECIPIENT_SELF: &str = "RECIPIENT_SELF";
|
||||
pub(crate) const RECIPIENT_1: &str = "RECIPIENT_1";
|
||||
pub(crate) const RECIPIENT_2: &str = "RECIPIENT_2";
|
||||
pub(crate) const CODE1_HMAC_SECRET: &str = "CODE1_HMAC_SECRET";
|
||||
|
||||
pub fn load_dotenv() -> Result<(), String> {
|
||||
static RUNTIME_SECRETS: OnceLock<RuntimeSecrets> = OnceLock::new();
|
||||
|
||||
pub(crate) struct RuntimeSecrets {
|
||||
pub(crate) dev_secret_key: String,
|
||||
pub(crate) textbee_api_key: String,
|
||||
pub(crate) textbee_device_id: Option<String>,
|
||||
pub(crate) recipient_self: String,
|
||||
pub(crate) recipient_1: String,
|
||||
pub(crate) recipient_2: String,
|
||||
pub(crate) code1_hmac_secret: String,
|
||||
}
|
||||
|
||||
pub fn init() -> Result<(), String> {
|
||||
match dotenvy::dotenv() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("Unable to load .env file: {error}")),
|
||||
Ok(_) => {}
|
||||
Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(format!("Unable to load .env file: {error}")),
|
||||
}
|
||||
|
||||
if RUNTIME_SECRETS.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let secrets = RuntimeSecrets::from_environment()?;
|
||||
let _ = RUNTIME_SECRETS.set(secrets);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn runtime() -> &'static RuntimeSecrets {
|
||||
RUNTIME_SECRETS
|
||||
.get()
|
||||
.expect("runtime secrets must be validated before the server starts")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_dev_secret_key(provided_key: &str) -> bool {
|
||||
provided_key == runtime().dev_secret_key
|
||||
}
|
||||
|
||||
impl RuntimeSecrets {
|
||||
fn from_environment() -> Result<Self, String> {
|
||||
let dev_secret_key = required(DEV_SECRET_KEY)?;
|
||||
let textbee_api_key = required(TEXTBEE_API_KEY)?;
|
||||
let recipient_self = required_recipient(RECIPIENT_SELF)?;
|
||||
let recipient_1 = required_recipient(RECIPIENT_1)?;
|
||||
let recipient_2 = required_recipient(RECIPIENT_2)?;
|
||||
let code1_hmac_secret = required(CODE1_HMAC_SECRET)?;
|
||||
|
||||
if code1_hmac_secret.len() < 32 {
|
||||
return Err(format!(
|
||||
"{CODE1_HMAC_SECRET} must contain at least 32 bytes"
|
||||
));
|
||||
}
|
||||
|
||||
let textbee_device_id = env::var(TEXTBEE_DEVICE_ID)
|
||||
.ok()
|
||||
.filter(|device_id| !device_id.trim().is_empty());
|
||||
|
||||
Ok(Self {
|
||||
dev_secret_key,
|
||||
textbee_api_key,
|
||||
textbee_device_id,
|
||||
recipient_self,
|
||||
recipient_1,
|
||||
recipient_2,
|
||||
code1_hmac_secret,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_dev_secret_key(provided_key: &str) -> Result<bool, ServerFnError> {
|
||||
let expected_key = std::env::var(DEV_SECRET_KEY).map_err(|_| {
|
||||
ServerFnError::new(format!(
|
||||
"Required server secret {DEV_SECRET_KEY} is not configured"
|
||||
))
|
||||
})?;
|
||||
fn required(name: &str) -> Result<String, String> {
|
||||
let value =
|
||||
env::var(name).map_err(|_| format!("Required environment variable {name} is missing"))?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("Required environment variable {name} is empty"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
Ok(provided_key == expected_key)
|
||||
fn required_recipient(name: &str) -> Result<String, String> {
|
||||
let recipient = required(name)?;
|
||||
if !is_e164_number(&recipient) {
|
||||
return Err(format!("{name} must be a valid E.164 phone number"));
|
||||
}
|
||||
Ok(recipient)
|
||||
}
|
||||
|
||||
fn is_e164_number(number: &str) -> bool {
|
||||
let digits = number.strip_prefix('+').unwrap_or_default();
|
||||
(8..=15).contains(&digits.len())
|
||||
&& !digits.starts_with('0')
|
||||
&& digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user