1
0
Fork 0
forked from 2sys/shoutdotdev
shoutdotdev/src/channels_router.rs

456 lines
16 KiB
Rust

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::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
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<Channel, AppError> {
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<AppState> {
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<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_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?;
#[derive(Template)]
#[template(path = "channels.html")]
struct ResponseTemplate {
base_path: String,
breadcrumbs: BreadcrumbTrail,
channels: Vec<Channel>,
csrf_token: String,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
.with_i18n_slug("en")
.push_slug("Teams", "teams")
.push_slug(&team.name, &team.id.simple().to_string())
.push_slug("Channels", "channels"),
base_path,
channels,
csrf_token,
navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_CHANNELS)
.build(),
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
struct NewChannelPostFormBody {
csrf_token: String,
channel_type: String,
}
async fn post_new_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<NewChannelPostFormBody>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_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<Channel, AppError>>(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::<BackendConfig>::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!(
"{}/en/teams/{}/channels/{}",
base_path,
team.id.simple(),
channel.id.simple()
)))
}
async fn channel_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_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?;
match channel.backend_config {
BackendConfig::Email(_) => {
#[derive(Template)]
#[template(path = "channel-email.html")]
struct ResponseTemplate {
base_path: String,
breadcrumbs: BreadcrumbTrail,
channel: Channel,
csrf_token: String,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
.with_i18n_slug("en")
.push_slug("Teams", "teams")
.push_slug(&team.name, &team.id.simple().to_string())
.push_slug("Channels", "channels")
.push_slug(&channel.name, &channel.id.simple().to_string()),
base_path,
channel,
csrf_token,
navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_CHANNELS)
.build(),
}
.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<String>,
}
async fn update_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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!(
"{}/en/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<Settings>,
DbConn(db_conn): DbConn,
State(mailer): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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!(
"{}/en/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<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<VerifyEmailFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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::<BackendConfig>::into(new_config)))
.execute(conn)?;
Ok(())
})
})
.await
.unwrap()?;
};
Ok(Redirect::to(&format!(
"{}/en/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
)))
}