diff --git a/crates/turnstile/Cargo.toml b/crates/turnstile/Cargo.toml new file mode 100644 index 00000000..8f249c89 --- /dev/null +++ b/crates/turnstile/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "turnstile" +version = "0.1.0" +edition = "2021" +description = "Cloudflare Turnstile token verification — port of pkg/turnstile" + +[dependencies] +anyhow = "1" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["time"] } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/turnstile/src/lib.rs b/crates/turnstile/src/lib.rs new file mode 100644 index 00000000..a77ccc25 --- /dev/null +++ b/crates/turnstile/src/lib.rs @@ -0,0 +1,60 @@ +//! Cloudflare Turnstile token verification. +//! Port of `server/pkg/turnstile`. + +use serde::{Deserialize, Serialize}; + +const VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +#[derive(Debug, Serialize)] +struct VerifyRequest<'a> { + secret: &'a str, + response: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + remoteip: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + idempotency_key: &'a str, +} + +#[derive(Debug, Deserialize)] +struct VerifyResponse { + success: bool, + #[serde(rename = "error-codes", default)] + error_codes: Vec, +} + +/// Verify a Turnstile challenge token. +/// +/// - `secret` — site secret key from Cloudflare dashboard +/// - `token` — value of `cf-turnstile-response` submitted by the browser +/// - `ip` — optional client IP (pass `""` to omit) +pub async fn verify(secret: &str, token: &str, ip: &str) -> anyhow::Result { + verify_with_key(secret, token, ip, "").await +} + +/// Verify with idempotency key (prevents the same token being accepted twice). +pub async fn verify_with_key( + secret: &str, + token: &str, + ip: &str, + idempotency_key: &str, +) -> anyhow::Result { + let client = reqwest::Client::new(); + let req = VerifyRequest { secret, response: token, remoteip: ip, idempotency_key }; + let resp: VerifyResponse = client + .post(VERIFY_URL) + .json(&req) + .send() + .await? + .json() + .await?; + + if !resp.success && !resp.error_codes.is_empty() { + tracing::warn!(codes = ?resp.error_codes, "turnstile verification failed"); + } + Ok(resp.success) +} + +/// Generate a random UUID suitable for use as an idempotency key. +pub fn random_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} diff --git a/src/exchange_rate.rs b/src/exchange_rate.rs new file mode 100644 index 00000000..53193e73 --- /dev/null +++ b/src/exchange_rate.rs @@ -0,0 +1,48 @@ +//! Exchange rate conversion via apilayer.com. +//! Port of `server/pkg/exchangeRate/exchangeRate.go`. + +use serde::Deserialize; + +const API_BASE: &str = "https://api.apilayer.com"; + +#[derive(Debug, Deserialize)] +struct ConvertResponse { + success: bool, + result: Option, +} + +/// Convert `amount` from currency `from` to currency `to`. +/// +/// - `access_key` — API key from apilayer.com (configured in `Config.Currency.AccessKey`) +/// +/// Returns the converted amount, or an error if the request fails or the API +/// reports failure. +pub async fn convert( + from: &str, + to: &str, + amount: f64, + access_key: &str, +) -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let resp: ConvertResponse = client + .get(format!("{API_BASE}/currency_data/convert")) + .header("apikey", access_key) + .query(&[ + ("from", from), + ("to", to), + ("amount", &amount.to_string()), + ]) + .send() + .await? + .json() + .await?; + + if !resp.success { + anyhow::bail!("exchange rate API returned failure for {from}→{to}"); + } + resp.result + .ok_or_else(|| anyhow::anyhow!("exchange rate API returned no result")) +}