use anyhow::Context as _; use askama::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Router, }; use axum_extra::extract::Form; use diesel::prelude::*; use rand::Rng as _; use regex::Regex; use serde::Deserialize; use uuid::Uuid; use crate::{ app_error::AppError, app_state::{AppState, DbConn}, channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL}, csrf::generate_csrf_token, email::{MailSender as _, Mailer}, guards, nav_state::{Breadcrumb, NavState}, schema::channels, settings::Settings, users::CurrentUser, }; const VERIFICATION_CODE_LEN: usize = 6; /// Helper function to query a channel from the database by ID and team, and /// return an appropriate error if no such channel exists. fn get_channel_by_params<'a>( conn: &mut PgConnection, team_id: &'a Uuid, channel_id: &'a Uuid, ) -> Result { match Channel::all() .filter(Channel::with_id(channel_id)) .filter(Channel::with_team(team_id)) .first(conn) { diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError( "Channel with that team and ID not found.".to_string(), )), diesel::QueryResult::Err(err) => Err(err.into()), diesel::QueryResult::Ok(channel) => Ok(channel), } } pub fn new_router() -> Router { Router::new() .route("/teams/{team_id}/channels", get(channels_page)) .route("/teams/{team_id}/channels/{channel_id}", get(channel_page)) .route( "/teams/{team_id}/channels/{channel_id}/update-channel", post(update_channel), ) .route( "/teams/{team_id}/channels/{channel_id}/update-email-recipient", post(update_channel_email_recipient), ) .route( "/teams/{team_id}/channels/{channel_id}/verify-email", post(verify_email), ) .route("/teams/{team_id}/new-channel", post(post_new_channel)) } async fn channels_page( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let channels = { db_conn .interact(move |conn| { Channel::all() .filter(Channel::with_team(&team_id)) .load(conn) }) .await .unwrap() .context("Failed to load channels list.")? }; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; let nav_state = NavState::new() .set_base_path(&base_path) .push_team(&team) .push_slug(Breadcrumb { href: "channels".to_string(), label: "Channels".to_string(), }) .set_navbar_active_item("channels"); #[derive(Template)] #[template(path = "channels.html")] struct ResponseTemplate { base_path: String, channels: Vec, csrf_token: String, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, channels, csrf_token, nav_state, } .render()?, ) .into_response()) } #[derive(Deserialize)] struct NewChannelPostFormBody { csrf_token: String, channel_type: String, } async fn post_new_channel( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form_body): Form, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; let channel_id = Uuid::now_v7(); let channel = match form_body.channel_type.as_str() { CHANNEL_BACKEND_EMAIL => db_conn .interact::<_, Result>(move |conn| { Ok(diesel::insert_into(channels::table) .values(( channels::id.eq(channel_id), channels::team_id.eq(team_id), channels::name.eq("Untitled Email Channel"), channels::backend_config .eq(Into::::into(EmailBackendConfig::default())), )) .returning(Channel::as_returning()) .get_result(conn) .context("Failed to insert new EmailChannel.")?) }) .await .unwrap()?, _ => { return Err(AppError::BadRequestError( "Channel type not recognized.".to_string(), )); } }; Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, team.id.simple(), channel.id.simple() ))) } async fn channel_page( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let channel = { match db_conn .interact(move |conn| { Channel::all() .filter(Channel::with_id(&channel_id)) .filter(Channel::with_team(&team_id)) .first(conn) .optional() }) .await .unwrap()? { None => { return Err(AppError::NotFoundError( "Channel with that team and ID not found".to_string(), )); } Some(channel) => channel, } }; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; let nav_state = NavState::new() .set_base_path(&base_path) .push_team(&team) .push_slug(Breadcrumb { href: "channels".to_string(), label: "Channels".to_string(), }) .push_slug(Breadcrumb { href: channel.id.simple().to_string(), label: channel.name.clone(), }) .set_navbar_active_item("channels"); match channel.backend_config { BackendConfig::Email(_) => { #[derive(Template)] #[template(path = "channel-email.html")] struct ResponseTemplate { base_path: String, channel: Channel, csrf_token: String, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, channel, csrf_token, nav_state, } .render()?, )) } BackendConfig::Slack(_) => { Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) } } } #[derive(Deserialize)] struct UpdateChannelFormBody { csrf_token: String, name: String, enable_by_default: Option, } async fn update_channel( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, Form(form_body): Form, ) -> Result { guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let updated_rows = { db_conn .interact(move |conn| { diesel::update( channels::table .filter(Channel::with_id(&channel_id)) .filter(Channel::with_team(&team_id)), ) .set(( channels::name.eq(form_body.name), channels::enable_by_default .eq(form_body.enable_by_default.unwrap_or("false".to_string()) == "true"), )) .execute(conn) }) .await .unwrap() .context("Failed to load Channel while updating.")? }; if updated_rows != 1 { return Err(AppError::NotFoundError( "Channel with that team and ID not found".to_string(), )); } Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, team_id.simple(), channel_id.simple() )) .into_response()) } #[derive(Deserialize)] struct UpdateChannelEmailRecipientFormBody { // Yes it's a mouthful, but it's only used twice csrf_token: String, recipient: String, } async fn update_channel_email_recipient( State(Settings { base_path, email: email_settings, .. }): State, DbConn(db_conn): DbConn, State(mailer): State, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, Form(form_body): Form, ) -> Result { guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; if !is_permissible_email(&form_body.recipient) { return Err(AppError::BadRequestError( "Unable to validate email address format.".to_string(), )); } let verification_code: String = rand::thread_rng() .sample_iter(&rand::distributions::Uniform::from(0..9)) .take(VERIFICATION_CODE_LEN) .map(|n| n.to_string()) .collect(); { let verification_code = verification_code.clone(); let recipient = form_body.recipient.clone(); db_conn .interact(move |conn| { // TODO: transaction retries conn.transaction::<_, AppError, _>(move |conn| { let channel = get_channel_by_params(conn, &team_id, &channel_id)?; let new_config = BackendConfig::Email(EmailBackendConfig { recipient, verification_code, verification_code_guesses: 0, ..channel.backend_config.try_into()? }); let num_rows = diesel::update(channels::table.filter(Channel::with_id(&channel.id))) .set(channels::backend_config.eq(new_config)) .execute(conn)?; if num_rows != 1 { return Err(anyhow::anyhow!( "Updating EmailChannel recipient, the channel was found but {} rows were updated.", num_rows ) .into()); } Ok(()) }) }) .await .unwrap()?; } tracing::debug!( "Email verification code for {} is: {}", form_body.recipient, verification_code ); tracing::info!( "Sending email verification code to: {}", form_body.recipient ); let email = crate::email::Message { from: email_settings.verification_from, to: form_body.recipient.parse()?, subject: "Verify Your Email".to_string(), text_body: format!("Your email verification code is: {}", verification_code), }; mailer.send_batch(vec![email]).await.remove(0)?; Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, team_id.simple(), channel_id.simple() ))) } /// Returns true if the email address matches a format recognized as "valid". /// Not all "legal" email addresses will be accepted, but addresses that are /// "illegal" and/or could result in unexpected behavior should be rejected. fn is_permissible_email(address: &str) -> bool { let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$") .expect("email validation regex should parse"); re.is_match(address) } #[derive(Deserialize)] struct VerifyEmailFormBody { csrf_token: String, code: String, } async fn verify_email( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, Form(form_body): Form, ) -> Result { guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; if form_body.code.len() != VERIFICATION_CODE_LEN { return Err(AppError::BadRequestError(format!( "Verification code must be {} characters long.", VERIFICATION_CODE_LEN ))); } { let verification_code = form_body.code; db_conn .interact(move |conn| { conn.transaction::<(), AppError, _>(move |conn| { let channel = get_channel_by_params(conn, &team_id, &channel_id)?; let config: EmailBackendConfig = channel.backend_config.try_into()?; if config.verified { return Err(AppError::BadRequestError( "Channel's email address is already verified.".to_string(), )); } const MAX_VERIFICATION_GUESSES: u32 = 100; if config.verification_code_guesses > MAX_VERIFICATION_GUESSES { return Err(AppError::BadRequestError( "Verification expired.".to_string(), )); } let new_config = if config.verification_code == verification_code { EmailBackendConfig { verified: true, verification_code: "".to_string(), verification_code_guesses: 0, ..config } } else { EmailBackendConfig { verification_code_guesses: config.verification_code_guesses + 1, ..config } }; diesel::update(channels::table.filter(Channel::with_id(&channel_id))) .set(channels::backend_config.eq(Into::::into(new_config))) .execute(conn)?; Ok(()) }) }) .await .unwrap()?; }; Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, team_id.simple(), channel_id.simple() ))) }