//! HTTP handlers for QTRNG-backed tools: dice, passwords, coin, 8 ball. use axum::{ extract::Query, http::{header, StatusCode}, response::{IntoResponse, Response}, }; use camera_trng::{ extract_entropy, roll_dice, generate_password, dice_bytes_needed, charset_alphanumeric, charset_full, charset_hex, charset_from_flags, filter_ambiguous, eightball_shake, eightball_bytes_needed, CameraConfig, DEFAULT_SIDES, DEFAULT_COUNT, MIN_SIDES, MAX_SIDES, PASSWORD_DEFAULT_LENGTH, PASSWORD_MAX_LENGTH, }; use serde::Deserialize; fn default_dice_sides() -> u32 { DEFAULT_SIDES } fn default_dice_count() -> usize { DEFAULT_COUNT } #[derive(Deserialize)] pub struct DiceQuery { #[serde(default = "default_dice_sides")] pub d: u32, #[serde(default = "default_dice_count")] pub count: usize, } #[derive(Deserialize)] pub struct PasswordQuery { #[serde(default)] pub length: Option, #[serde(default)] pub charset: Option, #[serde(default)] pub exclude_ambiguous: bool, } #[derive(Deserialize)] pub struct EightBallQuery { #[serde(default)] pub question: Option, } pub async fn get_dice(Query(params): Query) -> Response { let sides = params.d; let count = params.count; if sides < MIN_SIDES || sides > MAX_SIDES { return (StatusCode::BAD_REQUEST, format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)) .into_response(); } if count == 0 || count > camera_trng::DICE_MAX_COUNT { return (StatusCode::BAD_REQUEST, format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)) .into_response(); } let need = dice_bytes_needed(sides, count); let config = CameraConfig::from_env(); let result = tokio::task::spawn_blocking(move || { extract_entropy(need, &config) }).await; let data = match result { Ok(Ok(d)) => d, Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let rolls = match roll_dice(sides, count, &data) { Some(r) => r, None => return (StatusCode::INTERNAL_SERVER_ERROR, "dice roll failed").into_response(), }; let body = serde_json::json!({ "sides": sides, "count": count, "rolls": rolls, "sum": rolls.iter().sum::(), }); Response::builder() .header(header::CONTENT_TYPE, "application/json") .header(header::CACHE_CONTROL, "no-store") .body(axum::body::Body::from(body.to_string())) .unwrap() } pub async fn get_password(Query(params): Query) -> Response { let length = params.length .unwrap_or(PASSWORD_DEFAULT_LENGTH) .min(PASSWORD_MAX_LENGTH); if length == 0 { return (StatusCode::BAD_REQUEST, "length must be >= 1").into_response(); } let mut charset: Vec = match params.charset.as_deref() { Some("alphanumeric") | None => charset_alphanumeric(), Some("full") => charset_full(), Some("hex") => charset_hex(), Some(flags) => { let l = flags.to_lowercase(); charset_from_flags( l.contains('l'), l.contains('u'), l.contains('d'), l.contains('s'), ) } }; if params.exclude_ambiguous { charset = filter_ambiguous(&charset); if charset.is_empty() { return (StatusCode::BAD_REQUEST, "charset empty after removing ambiguous chars") .into_response(); } } let need = length * 4 + 64; let config = CameraConfig::from_env(); let result = tokio::task::spawn_blocking(move || { extract_entropy(need, &config) }).await; let data = match result { Ok(Ok(d)) => d, Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let password = match generate_password(length, &charset, &data) { Some(p) => p, None => return (StatusCode::INTERNAL_SERVER_ERROR, "generation failed").into_response(), }; let body = serde_json::json!({ "password": password, "length": length, "exclude_ambiguous": params.exclude_ambiguous, }); Response::builder() .header(header::CONTENT_TYPE, "application/json") .header(header::CACHE_CONTROL, "no-store") .body(axum::body::Body::from(body.to_string())) .unwrap() } /// Quantum coin flip: one random bit from QTRNG. pub async fn get_coin() -> Response { let config = CameraConfig::from_env(); let result = tokio::task::spawn_blocking(move || { extract_entropy(1, &config) }).await; let data = match result { Ok(Ok(d)) if !d.is_empty() => d[0], _ => return (StatusCode::INTERNAL_SERVER_ERROR, "entropy failed").into_response(), }; let side = if data & 1 == 0 { "heads" } else { "tails" }; let body = serde_json::json!({ "result": side }); Response::builder() .header(header::CONTENT_TYPE, "application/json") .header(header::CACHE_CONTROL, "no-store") .body(axum::body::Body::from(body.to_string())) .unwrap() } /// Quantum 8 Ball: ask a question, get a quantumly-random Magic 8 Ball answer. pub async fn get_eightball(Query(params): Query) -> Response { let need = eightball_bytes_needed(); let config = CameraConfig::from_env(); let result = tokio::task::spawn_blocking(move || { extract_entropy(need, &config) }).await; let data = match result { Ok(Ok(d)) => d, Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let (index, answer, sentiment) = match eightball_shake(&data) { Some(r) => r, None => return (StatusCode::INTERNAL_SERVER_ERROR, "8 ball shaking failed").into_response(), }; let mut body = serde_json::json!({ "answer": answer, "sentiment": sentiment, "index": index, }); if let Some(q) = params.question { body["question"] = serde_json::Value::String(q); } Response::builder() .header(header::CONTENT_TYPE, "application/json") .header(header::CACHE_CONTROL, "no-store") .body(axum::body::Body::from(body.to_string())) .unwrap() }