use std::collections::HashSet; use anyhow::{anyhow, Context}; use askama_axum::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Router, }; use axum_extra::extract::Form; use diesel::{delete, dsl::insert_into, prelude::*, update}; use rand::{distributions::Uniform, Rng}; use regex::Regex; use serde::Deserialize; use tower::ServiceBuilder; use tower_http::{ compression::CompressionLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, app_state::{AppState, DbConn}, auth, channel_selections::ChannelSelection, channels::Channel, csrf::generate_csrf_token, email::{MailSender as _, Mailer}, guards, nav_state::{Breadcrumb, NavState}, projects::Project, schema::{self, channel_selections, channels, email_channels}, settings::Settings, team_memberships::TeamMembership, teams::Team, users::CurrentUser, v0_router, }; pub fn new_router(state: AppState) -> Router<()> { let base_path = state.settings.base_path.clone(); Router::new().nest( base_path.as_str(), Router::new() .route("/", get(landing_page)) .merge(v0_router::new_router(state.clone())) .route("/teams", get(teams_page)) .route("/teams/{team_id}", get(team_page)) .route("/teams/{team_id}/projects", get(projects_page)) .route("/teams/{team_id}/projects/{project_id}", get(project_page)) .route( "/teams/{team_id}/projects/{project_id}/update-enabled-channels", post(update_enabled_channels), ) .route("/teams/{team_id}/new-api-key", post(post_new_api_key)) .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)) .route("/new-team", get(new_team_page)) .route("/new-team", post(post_new_team)) .nest("/auth", auth::new_router()) .fallback_service( ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")), ) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new()), ) .with_state(state), ) } async fn landing_page(State(state): State) -> impl IntoResponse { Redirect::to(&format!("{}/teams", state.settings.base_path)) } async fn teams_page( State(Settings { base_path, .. }): State, DbConn(conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let team_memberships_query = current_user.clone().team_memberships(); let teams: Vec = conn .interact(move |conn| team_memberships_query.load(conn)) .await .unwrap() .context("failed to load team memberships") .map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())?; let nav_state = NavState::new() .set_base_path(&base_path) .push_slug(Breadcrumb { href: "teams".to_string(), label: "New Team".to_string(), }) .set_navbar_active_item("teams"); #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { base_path: String, teams: Vec, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, nav_state, teams, } .render()?, )) } async fn team_page(State(state): State, Path(team_id): Path) -> impl IntoResponse { Redirect::to(&format!( "{}/teams/{}/projects", state.settings.base_path, team_id )) } #[derive(Deserialize)] struct PostNewApiKeyForm { csrf_token: String, } async fn post_new_api_key( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; ApiKey::generate_for_team(&db_conn, team.id.clone()).await?; Ok(Redirect::to(&format!( "{}/teams/{}/projects", base_path, team.id.hyphenated().to_string() )) .into_response()) } async fn new_team_page( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; let nav_state = NavState::new() .set_base_path(&base_path) .push_slug(Breadcrumb { href: "new-team".to_string(), label: "New Team".to_string(), }) .set_navbar_active_item("teams"); #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { base_path: String, csrf_token: String, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, csrf_token, nav_state, } .render()?, )) } #[derive(Deserialize)] struct PostNewTeamForm { name: String, csrf_token: String, } async fn post_new_team( DbConn(db_conn): DbConn, State(Settings { base_path, .. }): State, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team_id = Uuid::now_v7(); let team = Team { id: team_id.clone(), name: form.name, }; let team_membership = TeamMembership { team_id: team_id.clone(), user_id: current_user.id, roles: vec![Some("OWNER".to_string())], }; db_conn .interact(move |conn| { conn.transaction(move |conn| { insert_into(schema::teams::table) .values(team) .execute(conn)?; insert_into(schema::team_memberships::table) .values(team_membership) .execute(conn)?; diesel::QueryResult::Ok(()) }) }) .await .unwrap() .unwrap(); ApiKey::generate_for_team(&db_conn, team_id.clone()).await?; Ok(Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id)).into_response()) } async fn projects_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 api_keys_query = team.clone().api_keys(); let (api_keys, projects) = db_conn .interact(move |conn| { diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?)) }) .await .unwrap()?; #[derive(Template)] #[template(path = "projects.html")] struct ResponseTemplate { base_path: String, csrf_token: String, keys: Vec, nav_state: NavState, projects: Vec, } let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?; let nav_state = NavState::new() .set_base_path(&base_path) .push_team(&team) .push_slug(Breadcrumb { href: "projects".to_string(), label: "Projects".to_string(), }) .set_navbar_active_item("projects"); Ok(Html( ResponseTemplate { base_path, csrf_token, nav_state, projects, keys: api_keys, } .render()?, ) .into_response()) } 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 team_filter = Channel::with_team(team_id); let channels = db_conn .interact(move |conn| Channel::all().filter(team_filter).load(conn)) .await .unwrap() .context("Failed to load channels list.")?; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).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 = match form_body.channel_type.as_str() { "email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?, _ => { 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 id_filter = Channel::with_id(channel_id); let team_filter = Channel::with_team(team_id.clone()); let channel = match db_conn .interact(move |conn| { Channel::all() .filter(id_filter) .filter(team_filter) .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.clone())).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"); if channel.email_data.is_some() { #[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()?, )) } else if channel.slack_data.is_some() { Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) } else { Err(anyhow::anyhow!( "Channel doesn't have a recognized variant for which to render a config page." ) .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 id_filter = Channel::with_id(channel_id.clone()); let team_filter = Channel::with_team(team_id.clone()); let updated_rows = db_conn .interact(move |conn| { update(channels::table.filter(id_filter).filter(team_filter)) .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(&Uniform::try_from(0..9).unwrap()) .take(6) .map(|n| n.to_string()) .collect(); let verification_code_copy = verification_code.clone(); let recipient_copy = form_body.recipient.clone(); let channel_id_filter = Channel::with_id(channel_id.clone()); let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); let team_filter = Channel::with_team(team_id.clone()); let updated_rows = db_conn .interact(move |conn| { if Channel::all() .filter(channel_id_filter) .filter(team_filter) .filter(email_channel_id_filter.clone()) .first(conn) .optional() .context("failed to check whether channel exists under team") .map_err(Into::::into)? .is_none() { return Err(AppError::NotFoundError( "Channel with that team and ID not found.".to_string(), )); } update(email_channels::table.filter(email_channel_id_filter)) .set(( email_channels::recipient.eq(recipient_copy), email_channels::verification_code.eq(verification_code_copy), email_channels::verification_code_guesses.eq(0), )) .execute(conn) .map_err(Into::::into) }) .await .unwrap()?; if updated_rows != 1 { return Err(anyhow!( "Updating EmailChannel recipient, the channel was found but {} rows were updated.", updated_rows ) .into()); } 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.into(), 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?; 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?; let channel_id_filter = Channel::with_id(channel_id.clone()); let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); let team_filter = Channel::with_team(team_id.clone()); let updated_rows = db_conn .interact(move |conn| { if Channel::all() .filter(channel_id_filter) .filter(team_filter) .filter(email_channel_id_filter.clone()) .first(conn) .optional() .context("failed to check whether channel exists under team") .map_err(Into::::into)? .is_none() { return Err(AppError::NotFoundError( "Channel with that team and ID not found.".to_string(), )); } update(email_channels::table.filter(email_channel_id_filter)) .set( email_channels::verification_code_guesses .eq(email_channels::verification_code_guesses + 1), ) .execute(conn) .map_err(Into::::into)?; update( email_channels::table .filter(email_channel_id_filter) .filter(email_channels::verification_code.eq(form_body.code)), ) .set(email_channels::verified.eq(true)) .execute(conn) .map_err(Into::::into) }) .await .unwrap()?; if updated_rows != 1 { return Err(AppError::BadRequestError( "Verification code not accepted.".to_string(), )); } Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, team_id.simple(), channel_id.simple() ))) } async fn project_page( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path((team_id, project_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let project_id_filter = Project::with_id(project_id.clone()); let project_team_filter = Project::with_team(team_id.clone()); let project = db_conn .interact(move |conn| { match Project::all() .filter(project_id_filter) .filter(project_team_filter) .first(conn) { diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError( "Project with that team and ID not found.".to_string(), )), other => other .context("failed to load project") .map_err(|err| err.into()), } }) .await .unwrap()?; let selected_channels_query = project.selected_channels(); let enabled_channel_ids: HashSet = db_conn .interact(move |conn| selected_channels_query.load(conn)) .await .unwrap() .context("failed to load selected channels")? .iter() .map(|channel| channel.id) .collect(); let team_filter = Channel::with_team(team.id.clone()); let team_channels = db_conn .interact(move |conn| Channel::all().filter(team_filter).load(conn)) .await .unwrap() .context("failed to load team channels")?; 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_project(&project)?; #[derive(Template)] #[template(path = "project.html")] struct ResponseTemplate { base_path: String, csrf_token: String, enabled_channel_ids: HashSet, nav_state: NavState, project: Project, team_channels: Vec, } Ok(Html( ResponseTemplate { base_path, csrf_token, enabled_channel_ids, project, nav_state, team_channels, } .render()?, )) } #[derive(Deserialize)] struct UpdateEnabledChannelsFormBody { csrf_token: String, #[serde(default)] enabled_channels: Vec, } async fn update_enabled_channels( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path((team_id, project_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 id_filter = Project::with_id(project_id.clone()); let team_filter = Project::with_team(team_id.clone()); db_conn .interact(move |conn| -> Result<(), AppError> { let project = match Project::all() .filter(id_filter) .filter(team_filter) .first(conn) { diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError( "Project with that team and ID not found.".to_string(), )), other => other .context("failed to load project") .map_err(|err| err.into()), }?; delete( channel_selections::table .filter(ChannelSelection::with_project(project.id.clone())) .filter(channel_selections::channel_id.ne_all(&form_body.enabled_channels)), ) .execute(conn) .context("failed to remove unset channel selections")?; for channel_id in form_body.enabled_channels { insert_into(channel_selections::table) .values(( channel_selections::project_id.eq(&project.id), channel_selections::channel_id.eq(channel_id), )) .on_conflict_do_nothing() .execute(conn) .context("failed to insert channel selections")?; } Ok(()) }) .await .unwrap()?; Ok(Redirect::to(&format!( "{}/teams/{}/projects/{}", base_path, team_id, project_id )) .into_response()) }