forked from 2sys/shoutdotdev
456 lines
16 KiB
Rust
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(¤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?;
|
|
#[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(¤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<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(¤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?;
|
|
|
|
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, ¤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!(
|
|
"{}/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, ¤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!(
|
|
"{}/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, ¤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::<BackendConfig>::into(new_config)))
|
|
.execute(conn)?;
|
|
Ok(())
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()?;
|
|
};
|
|
|
|
Ok(Redirect::to(&format!(
|
|
"{}/en/teams/{}/channels/{}",
|
|
base_path,
|
|
team_id.simple(),
|
|
channel_id.simple()
|
|
)))
|
|
}
|