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
+8
View File
@@ -12,8 +12,12 @@ leptos_router = { version = "0.8.0" }
axum = { version = "0.8.0", optional = true } axum = { version = "0.8.0", optional = true }
console_error_panic_hook = { version = "0.1", optional = true } console_error_panic_hook = { version = "0.1", optional = true }
dotenvy = { version = "0.15", optional = true } dotenvy = { version = "0.15", optional = true }
hmac = { version = "0.12", optional = true }
leptos_axum = { version = "0.8.0", optional = true } leptos_axum = { version = "0.8.0", optional = true }
leptos_meta = { version = "0.8.0" } leptos_meta = { version = "0.8.0" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
serde_json = { version = "1", optional = true }
sha2 = { version = "0.10", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "0.2.106", optional = true } wasm-bindgen = { version = "0.2.106", optional = true }
@@ -26,8 +30,12 @@ hydrate = [
ssr = [ ssr = [
"dep:axum", "dep:axum",
"dep:dotenvy", "dep:dotenvy",
"dep:hmac",
"dep:tokio", "dep:tokio",
"dep:leptos_axum", "dep:leptos_axum",
"dep:reqwest",
"dep:serde_json",
"dep:sha2",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
+96 -2
View File
@@ -38,8 +38,9 @@ cargo leptos watch
Copy `dev.env` to `.env`, then replace the placeholder with a unique secret Copy `dev.env` to `.env`, then replace the placeholder with a unique secret
before starting the server. The `.env` file is loaded automatically at startup before starting the server. The `.env` file is loaded automatically at startup
and is ignored by Git. `DEV_SECRET_KEY` authorizes server status changes and and is ignored by Git. Startup validates the required settings and stops with a
submission-history access: clear error if any are missing or malformed. `DEV_SECRET_KEY` authorizes server
status changes and submission-history access:
```powershell ```powershell
Copy-Item dev.env .env Copy-Item dev.env .env
@@ -48,6 +49,99 @@ Copy-Item dev.env .env
You can also provide `DEV_SECRET_KEY` through the process environment. Do not You can also provide `DEV_SECRET_KEY` through the process environment. Do not
commit secrets or expose them in client-side code. commit secrets or expose them in client-side code.
Code 1 sends an SMS through TextBee when the server is live. Set
`TEXTBEE_API_KEY`, `RECIPIENT_SELF`, `RECIPIENT_1`, and `RECIPIENT_2` (each a
single E.164 number) in `.env`; `TEXTBEE_DEVICE_ID` is optional and selects a
specific paired device. When live, code 1 sends a notification to self, the
congratulations message to recipients 1 and 2, then `CHALLENGE_1_FIRST` to
recipient 1 and `CHALLENGE_1_SECOND` to recipient 2. The latter two are sent
literally as written. When offline, the planned messages are logged instead.
TextBee acceptance means a message was queued, not necessarily delivered.
Code 2 uses the former manual code value (`Qj4eAn`). On successful submission,
it sends a notification to `RECIPIENT_SELF` while the server is live (and logs
the notification instead while offline). The result page displays
`NEXT_STEP_TEXT` from `src/codes/code2.rs`; replace its TODO text with the next
step when ready.
The robot submits code 1 with `POST /event/buzz` and a JSON body containing
`delta_ms` (a signed integer) and `signature` (hex-encoded HMAC-SHA256). The
signature is the HMAC of the UTF-8 decimal representation of `delta_ms`, using
`CODE1_HMAC_SECRET` as the key. Set `CODE1_HMAC_SECRET` to a unique random
secret of at least 32 bytes in `.env`; for example:
```json
{"delta_ms":-42,"signature":"<64 hex characters>"}
```
### Test code1 with Postman
Create a `POST` request to `http://127.0.0.1:3000/event/buzz`, select **Body >
raw > JSON**, and use this body:
```json
{
"delta_ms": {{delta_ms}},
"signature": "{{buzz_signature}}"
}
```
Add a Postman pre-request script to calculate the signature:
```javascript
const deltaMs = pm.environment.get("delta_ms") || "-42";
const secret = pm.environment.get("CODE1_HMAC_SECRET");
if (!secret) {
throw new Error("Set CODE1_HMAC_SECRET in the active Postman environment");
}
pm.environment.set("delta_ms", deltaMs);
pm.environment.set(
"buzz_signature",
CryptoJS.HmacSHA256(String(deltaMs), secret).toString(CryptoJS.enc.Hex)
);
```
Set `CODE1_HMAC_SECRET` in the active Postman environment to the same value as
in `.env`. Set `delta_ms` to a signed integer (for example `-42`) to change the
test event. A successful request returns HTTP `202`.
### Test code1 with Node.js
With the server running and `CODE1_HMAC_SECRET` set in your shell, this sends a
single test event:
```javascript
const { createHmac } = require("node:crypto");
async function main() {
const deltaMs = -42;
const secret = process.env.CODE1_HMAC_SECRET;
if (!secret) throw new Error("CODE1_HMAC_SECRET is not set");
const signature = createHmac("sha256", secret)
.update(String(deltaMs), "utf8")
.digest("hex");
const response = await fetch("http://127.0.0.1:3000/event/buzz", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ delta_ms: deltaMs, signature }),
});
console.log(response.status, await response.text());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
When the server is live, a successful test event sends the configured TextBee
SMS to the configured recipients. When it is offline, it only logs the message.
## Installing Additional Tools ## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools. By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
+6
View File
@@ -1 +1,7 @@
DEV_SECRET_KEY=replace-with-a-unique-secret DEV_SECRET_KEY=replace-with-a-unique-secret
TEXTBEE_API_KEY=replace-with-your-textbee-api-key
TEXTBEE_DEVICE_ID=
RECIPIENT_SELF=+12015550123
RECIPIENT_1=+12015550124
RECIPIENT_2=+12015550125
CODE1_HMAC_SECRET=replace-with-a-unique-random-secret
+178 -5
View File
@@ -1,6 +1,179 @@
pub const ID: &str = "code1"; use axum::{extract::ConnectInfo, http::StatusCode, Json};
pub const CODE: &str = "Qj4eAn"; use hmac::{Hmac, Mac};
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted."; use serde_json::Value;
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later."; 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( async fn get_submission_history(
secret_key: String, secret_key: String,
) -> Result<Vec<HistoryViewEntry>, ServerFnError> { ) -> 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")); return Err(ServerFnError::new("Invalid secret key"));
} }
@@ -243,7 +243,7 @@ fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnE
#[server] #[server]
async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> { 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")); return Err(ServerFnError::new("Invalid secret key"));
} }
+14 -18
View File
@@ -6,31 +6,27 @@ use leptos_router::{
}; };
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod code1; pub mod code1;
#[cfg(feature = "ssr")]
pub mod code2;
pub mod history; pub mod history;
#[server] #[server]
async fn submit_code(code: String) -> Result<String, ServerFnError> { async fn submit_code(code: String) -> Result<String, ServerFnError> {
let message = if code == crate::codes::code1::CODE { if code != crate::codes::code2::CODE {
let live = crate::manage::server_live_state(); return Ok("This code is not recognized.".to_string());
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> = }
leptos_axum::extract().await?;
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); Ok(crate::codes::code2::NEXT_STEP_TEXT.to_string())
if live {
crate::codes::code1::LIVE_MESSAGE
} else {
crate::codes::code1::NOT_LIVE_MESSAGE
}
} else {
"This code is not recognized."
};
Ok(message.to_string())
} }
fn encode_path_segment(value: &str) -> String { fn encode_path_segment(value: &str) -> String {
+5 -1
View File
@@ -7,7 +7,7 @@ async fn main() {
use leptos::prelude::*; use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; 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 conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr; let addr = conf.leptos_options.site_addr;
@@ -16,6 +16,10 @@ async fn main() {
let routes = generate_route_list(App); let routes = generate_route_list(App);
let app = Router::new() let app = Router::new()
.route(
"/event/buzz",
axum::routing::post(code_submission::codes::code1::buzz_event_handler),
)
.leptos_routes(&leptos_options, routes, { .leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone(); let leptos_options = leptos_options.clone();
move || shell(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] #[server]
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> { 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())); return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
} }
+93 -13
View File
@@ -1,22 +1,102 @@
use leptos::prelude::ServerFnError; use std::{env, io::ErrorKind, sync::OnceLock};
use std::io::ErrorKind;
pub(crate) const DEV_SECRET_KEY: &str = "DEV_SECRET_KEY"; 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() { match dotenvy::dotenv() {
Ok(_) => Ok(()), Ok(_) => {}
Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => Ok(()), Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => {}
Err(error) => Err(format!("Unable to load .env file: {error}")), 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> { fn required(name: &str) -> Result<String, String> {
let expected_key = std::env::var(DEV_SECRET_KEY).map_err(|_| { let value =
ServerFnError::new(format!( env::var(name).map_err(|_| format!("Required environment variable {name} is missing"))?;
"Required server secret {DEV_SECRET_KEY} is not configured" 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())
} }