add code 2
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-16
@@ -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 {
|
||||||
|
return Ok("This code is not recognized.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let live = crate::manage::server_live_state();
|
let live = crate::manage::server_live_state();
|
||||||
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
||||||
leptos_axum::extract().await?;
|
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)?;
|
||||||
|
|
||||||
history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?;
|
Ok(crate::codes::code2::NEXT_STEP_TEXT.to_string())
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_path_segment(value: &str) -> String {
|
fn encode_path_segment(value: &str) -> String {
|
||||||
|
|||||||
+5
-1
@@ -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
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user