add code 2

This commit is contained in:
2026-10-12 15:15:26 -05:00
parent 370fe2e4aa
commit 8f0b2931e9
10 changed files with 443 additions and 42 deletions
+178 -5
View File
@@ -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(())
}
+40
View File
@@ -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(())
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}