Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
8.8 kB · 309 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310use axum::{ Json, extract::State, http::StatusCode, response::{IntoResponse, Response},};use ed25519_dalek::{Signature, Verifier, VerifyingKey};use serde::Deserialize;use serde_json::json;use tracing::{debug, info, warn};use tranquil_types::Handle;
use tranquil_pds::comms::comms_repo;use tranquil_pds::state::AppState;use tranquil_pds::util::discord_public_key;
#[derive(Deserialize)]struct Interaction { #[serde(rename = "type")] interaction_type: u8, data: Option<InteractionData>, member: Option<InteractionMember>, user: Option<InteractionUser>,}
#[derive(Deserialize)]struct InteractionData { name: Option<String>, options: Option<Vec<InteractionOption>>,}
#[derive(Deserialize)]struct InteractionOption { name: String, value: serde_json::Value,}
#[derive(Deserialize)]struct InteractionMember { user: Option<InteractionUser>,}
#[derive(Deserialize)]struct InteractionUser { id: String, username: Option<String>,}
fn verify_signature( public_key: &VerifyingKey, timestamp: &str, body: &str, signature: &str,) -> bool { let sig_bytes = match hex::decode(signature) { Ok(b) => b, Err(_) => return false, }; let signature = match Signature::from_slice(&sig_bytes) { Ok(s) => s, Err(_) => return false, }; let message = format!("{}{}", timestamp, body); public_key.verify(message.as_bytes(), &signature).is_ok()}
fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> { options .and_then(|opts| opts.iter().find(|o| o.name == "handle")) .and_then(|o| o.value.as_str()) .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string())}
pub async fn handle_discord_webhook( State(state): State<AppState>, headers: axum::http::HeaderMap, body: String,) -> Response { let public_key = match discord_public_key() { Some(pk) => pk, None => { warn!("Discord webhook called but public key is not configured"); return StatusCode::FORBIDDEN.into_response(); } };
let signature = headers .get("x-signature-ed25519") .and_then(|v| v.to_str().ok()) .unwrap_or_default(); let timestamp = headers .get("x-signature-timestamp") .and_then(|v| v.to_str().ok()) .unwrap_or_default();
if !verify_signature(public_key, timestamp, &body, signature) { return StatusCode::UNAUTHORIZED.into_response(); }
let interaction: Interaction = match serde_json::from_str(&body) { Ok(i) => i, Err(_) => return StatusCode::BAD_REQUEST.into_response(), };
match interaction.interaction_type { 1 => Json(json!({"type": 1})).into_response(), 2 => handle_command(state, interaction).await, other => { debug!( interaction_type = other, "Received unknown Discord interaction type" ); StatusCode::OK.into_response() } }}
async fn handle_command(state: AppState, interaction: Interaction) -> Response { let command_name = interaction .data .as_ref() .and_then(|d| d.name.as_deref()) .unwrap_or_default();
if command_name != "start" { return Json(json!({ "type": 4, "data": {"content": "Unknown command", "flags": 64} })) .into_response(); }
let user = interaction .member .as_ref() .and_then(|m| m.user.as_ref()) .or(interaction.user.as_ref());
let (discord_user_id, discord_username) = match user { Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()), None => { return Json(json!({ "type": 4, "data": {"content": "Could not identify user", "flags": 64} })) .into_response(); } };
let handle = match parse_start_handle( interaction.data.as_ref().and_then(|d| d.options.as_deref()), ) .map(Handle::new) .transpose() { Ok(h) => h, Err(_) => { return Json(json!({ "type": 4, "data": {"content": "Invalid handle format. Handle should look like: nel.oyster.cafe", "flags": 64} })) .into_response(); } };
debug!( discord_username = %discord_username, discord_user_id = %discord_user_id, handle = ?handle, "Received /start from Discord user" );
match state .repos .user .store_discord_user_id(&discord_username, &discord_user_id, handle.as_ref()) .await { Ok(Some(user_id)) => { info!( discord_username = %discord_username, discord_user_id = %discord_user_id, "Verified Discord user and stored user ID" ); if let Err(e) = comms_repo::enqueue_channel_verified( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, tranquil_db_traits::CommsChannel::Discord, &discord_user_id, &tranquil_config::get().server.hostname, ) .await { warn!(error = %e, "Failed to enqueue channel verified notification"); } Json(json!({ "type": 4, "data": {"content": "Verified", "flags": 64} })) .into_response() } Ok(None) => { debug!( discord_username = %discord_username, "No matching user found for Discord username" ); Json(json!({ "type": 4, "data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64} })) .into_response() } Err(tranquil_db_traits::DbError::Ambiguous(msg)) => { debug!( discord_username = %discord_username, "Ambiguous Discord username match" ); Json(json!({ "type": 4, "data": {"content": msg, "flags": 64} })) .into_response() } Err(tranquil_db_traits::DbError::LockContention) => { debug!( discord_username = %discord_username, "Lock contention during Discord verification" ); Json(json!({ "type": 4, "data": {"content": "Server busy, please try again in a moment.", "flags": 64} })) .into_response() } Err(e) => { warn!( discord_username = %discord_username, error = %e, "Failed to store Discord user ID" ); Json(json!({ "type": 4, "data": {"content": "Verification failed. Try again later.", "flags": 64} })) .into_response() } }}
#[cfg(test)]mod tests { use super::*;
#[test] fn parse_handle_from_options() { let options = vec![InteractionOption { name: "handle".to_string(), value: serde_json::json!("lewis.buttercup.wizardry.systems"), }]; assert_eq!( parse_start_handle(Some(&options)), Some("lewis.buttercup.wizardry.systems".to_string()), ); }
#[test] fn parse_handle_no_options() { assert_eq!(parse_start_handle(None), None); }
#[test] fn parse_handle_empty_options() { let options: Vec<InteractionOption> = vec![]; assert_eq!(parse_start_handle(Some(&options)), None); }
#[test] fn parse_handle_wrong_option_name() { let options = vec![InteractionOption { name: "other".to_string(), value: serde_json::json!("test"), }]; assert_eq!(parse_start_handle(Some(&options)), None); }
#[test] fn parse_handle_empty_string() { let options = vec![InteractionOption { name: "handle".to_string(), value: serde_json::json!(""), }]; assert_eq!(parse_start_handle(Some(&options)), None); }
#[test] fn parse_handle_whitespace_trimmed() { let options = vec![InteractionOption { name: "handle".to_string(), value: serde_json::json!(" alice.example.com "), }]; assert_eq!( parse_start_handle(Some(&options)), Some("alice.example.com".to_string()), ); }}