2025-02-26 13:10:45 -08:00
|
|
|
use std::collections::HashSet;
|
|
|
|
|
|
|
|
use anyhow::{anyhow, Context};
|
2025-02-26 13:10:50 -08:00
|
|
|
use askama_axum::Template;
|
|
|
|
use axum::{
|
|
|
|
extract::{Path, State},
|
|
|
|
response::{Html, IntoResponse, Redirect},
|
|
|
|
routing::{get, post},
|
2025-02-26 13:10:45 -08:00
|
|
|
Router,
|
2025-02-26 13:10:50 -08:00
|
|
|
};
|
2025-02-26 13:10:45 -08:00
|
|
|
use axum_extra::extract::Form;
|
|
|
|
use diesel::{delete, dsl::insert_into, prelude::*, update};
|
|
|
|
use rand::{distributions::Uniform, Rng};
|
|
|
|
use regex::Regex;
|
2025-02-26 13:10:50 -08:00
|
|
|
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,
|
2025-02-26 13:10:43 -08:00
|
|
|
app_state::{AppState, DbConn},
|
2025-02-26 13:10:48 -08:00
|
|
|
auth,
|
2025-02-26 13:10:45 -08:00
|
|
|
channel_selections::ChannelSelection,
|
2025-02-26 13:10:27 -08:00
|
|
|
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
|
2025-02-26 13:10:48 -08:00
|
|
|
csrf::generate_csrf_token,
|
2025-02-26 13:10:43 -08:00
|
|
|
email::{MailSender as _, Mailer},
|
2025-02-26 13:10:48 -08:00
|
|
|
guards,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state::{Breadcrumb, NavState},
|
2025-02-26 13:10:50 -08:00
|
|
|
projects::Project,
|
2025-02-26 13:10:27 -08:00
|
|
|
schema::{self, channel_selections, channels},
|
2025-02-26 13:10:48 -08:00
|
|
|
settings::Settings,
|
|
|
|
team_memberships::TeamMembership,
|
|
|
|
teams::Team,
|
2025-02-26 13:10:46 -08:00
|
|
|
users::CurrentUser,
|
2025-02-26 13:10:47 -08:00
|
|
|
v0_router,
|
2025-02-26 13:10:50 -08:00
|
|
|
};
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
const VERIFICATION_CODE_LEN: usize = 6;
|
|
|
|
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
|
|
|
|
2025-02-26 13:10:50 -08:00
|
|
|
pub fn new_router(state: AppState) -> Router<()> {
|
|
|
|
let base_path = state.settings.base_path.clone();
|
|
|
|
Router::new().nest(
|
2025-02-26 13:10:45 -08:00
|
|
|
base_path.as_str(),
|
2025-02-26 13:10:50 -08:00
|
|
|
Router::new()
|
|
|
|
.route("/", get(landing_page))
|
2025-02-26 13:10:46 -08:00
|
|
|
.merge(v0_router::new_router(state.clone()))
|
2025-02-26 13:10:50 -08:00
|
|
|
.route("/teams", get(teams_page))
|
|
|
|
.route("/teams/{team_id}", get(team_page))
|
|
|
|
.route("/teams/{team_id}/projects", get(projects_page))
|
2025-02-26 13:10:45 -08:00
|
|
|
.route("/teams/{team_id}/projects/{project_id}", get(project_page))
|
|
|
|
.route(
|
|
|
|
"/teams/{team_id}/projects/{project_id}/update-enabled-channels",
|
|
|
|
post(update_enabled_channels),
|
|
|
|
)
|
2025-02-26 13:10:50 -08:00
|
|
|
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
2025-02-26 13:10:45 -08:00
|
|
|
.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))
|
2025-02-26 13:10:50 -08:00
|
|
|
.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<AppState>) -> impl IntoResponse {
|
|
|
|
Redirect::to(&format!("{}/teams", state.settings.base_path))
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn teams_page(
|
2025-02-26 13:10:48 -08:00
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(conn): DbConn,
|
2025-02-26 13:10:50 -08:00
|
|
|
CurrentUser(current_user): CurrentUser,
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-02-26 13:10:47 -08:00
|
|
|
let team_memberships_query = current_user.clone().team_memberships();
|
|
|
|
let teams: Vec<Team> = conn
|
|
|
|
.interact(move |conn| team_memberships_query.load(conn))
|
2025-02-26 13:10:50 -08:00
|
|
|
.await
|
2025-02-26 13:10:47 -08:00
|
|
|
.unwrap()
|
|
|
|
.context("failed to load team memberships")
|
|
|
|
.map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())?;
|
2025-02-26 13:10:46 -08:00
|
|
|
let nav_state = NavState::new()
|
|
|
|
.set_base_path(&base_path)
|
|
|
|
.push_slug(Breadcrumb {
|
|
|
|
href: "teams".to_string(),
|
2025-02-26 13:10:27 -08:00
|
|
|
label: "Teams".to_string(),
|
2025-02-26 13:10:46 -08:00
|
|
|
})
|
|
|
|
.set_navbar_active_item("teams");
|
2025-02-26 13:10:50 -08:00
|
|
|
#[derive(Template)]
|
|
|
|
#[template(path = "teams.html")]
|
|
|
|
struct ResponseTemplate {
|
|
|
|
base_path: String,
|
|
|
|
teams: Vec<Team>,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state: NavState,
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
Ok(Html(
|
|
|
|
ResponseTemplate {
|
2025-02-26 13:10:48 -08:00
|
|
|
base_path,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state,
|
2025-02-26 13:10:47 -08:00
|
|
|
teams,
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
.render()?,
|
2025-02-26 13:10:48 -08:00
|
|
|
))
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async fn team_page(State(state): State<AppState>, Path(team_id): Path<Uuid>) -> 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(
|
2025-02-26 13:10:48 -08:00
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(db_conn): DbConn,
|
2025-02-26 13:10:50 -08:00
|
|
|
Path(team_id): Path<Uuid>,
|
2025-02-26 13:10:48 -08:00
|
|
|
CurrentUser(current_user): CurrentUser,
|
2025-02-26 13:10:50 -08:00
|
|
|
Form(form): Form<PostNewApiKeyForm>,
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-02-26 13:10:47 -08:00
|
|
|
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?;
|
2025-02-26 13:10:48 -08:00
|
|
|
|
|
|
|
ApiKey::generate_for_team(&db_conn, team.id.clone()).await?;
|
2025-02-26 13:10:50 -08:00
|
|
|
Ok(Redirect::to(&format!(
|
|
|
|
"{}/teams/{}/projects",
|
2025-02-26 13:10:48 -08:00
|
|
|
base_path,
|
|
|
|
team.id.hyphenated().to_string()
|
2025-02-26 13:10:50 -08:00
|
|
|
))
|
|
|
|
.into_response())
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn new_team_page(
|
2025-02-26 13:10:48 -08:00
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(db_conn): DbConn,
|
2025-02-26 13:10:50 -08:00
|
|
|
CurrentUser(current_user): CurrentUser,
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-02-26 13:10:48 -08:00
|
|
|
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
|
2025-02-26 13:10:47 -08:00
|
|
|
|
2025-02-26 13:10:46 -08:00
|
|
|
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");
|
2025-02-26 13:10:50 -08:00
|
|
|
#[derive(Template)]
|
|
|
|
#[template(path = "new-team.html")]
|
|
|
|
struct ResponseTemplate {
|
|
|
|
base_path: String,
|
|
|
|
csrf_token: String,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state: NavState,
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
Ok(Html(
|
|
|
|
ResponseTemplate {
|
2025-02-26 13:10:48 -08:00
|
|
|
base_path,
|
2025-02-26 13:10:50 -08:00
|
|
|
csrf_token,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state,
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
.render()?,
|
|
|
|
))
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
struct PostNewTeamForm {
|
|
|
|
name: String,
|
|
|
|
csrf_token: String,
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn post_new_team(
|
2025-02-26 13:10:48 -08:00
|
|
|
DbConn(db_conn): DbConn,
|
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
CurrentUser(current_user): CurrentUser,
|
2025-02-26 13:10:50 -08:00
|
|
|
Form(form): Form<PostNewTeamForm>,
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-02-26 13:10:47 -08:00
|
|
|
guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?;
|
2025-02-26 13:10:48 -08:00
|
|
|
|
2025-02-26 13:10:50 -08:00
|
|
|
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,
|
|
|
|
};
|
2025-02-26 13:10:48 -08:00
|
|
|
db_conn
|
2025-02-26 13:10:50 -08:00
|
|
|
.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();
|
2025-02-26 13:10:48 -08:00
|
|
|
ApiKey::generate_for_team(&db_conn, team_id.clone()).await?;
|
|
|
|
Ok(Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id)).into_response())
|
2025-02-26 13:10:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
async fn projects_page(
|
2025-02-26 13:10:48 -08:00
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(db_conn): DbConn,
|
2025-02-26 13:10:50 -08:00
|
|
|
Path(team_id): Path<Uuid>,
|
|
|
|
CurrentUser(current_user): CurrentUser,
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-02-26 13:10:47 -08:00
|
|
|
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
2025-02-26 13:10:48 -08:00
|
|
|
|
2025-02-26 13:10:47 -08:00
|
|
|
let api_keys_query = team.clone().api_keys();
|
|
|
|
let (api_keys, projects) = db_conn
|
2025-02-26 13:10:50 -08:00
|
|
|
.interact(move |conn| {
|
2025-02-26 13:10:47 -08:00
|
|
|
diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?))
|
2025-02-26 13:10:50 -08:00
|
|
|
})
|
|
|
|
.await
|
2025-02-26 13:10:48 -08:00
|
|
|
.unwrap()?;
|
2025-02-26 13:10:47 -08:00
|
|
|
|
2025-02-26 13:10:50 -08:00
|
|
|
#[derive(Template)]
|
|
|
|
#[template(path = "projects.html")]
|
|
|
|
struct ResponseTemplate {
|
|
|
|
base_path: String,
|
|
|
|
csrf_token: String,
|
|
|
|
keys: Vec<ApiKey>,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state: NavState,
|
2025-02-26 13:10:50 -08:00
|
|
|
projects: Vec<Project>,
|
|
|
|
}
|
2025-02-26 13:10:48 -08:00
|
|
|
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?;
|
2025-02-26 13:10:46 -08:00
|
|
|
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");
|
2025-02-26 13:10:50 -08:00
|
|
|
Ok(Html(
|
|
|
|
ResponseTemplate {
|
2025-02-26 13:10:48 -08:00
|
|
|
base_path,
|
2025-02-26 13:10:50 -08:00
|
|
|
csrf_token,
|
2025-02-26 13:10:46 -08:00
|
|
|
nav_state,
|
2025-02-26 13:10:47 -08:00
|
|
|
projects,
|
2025-02-26 13:10:50 -08:00
|
|
|
keys: api_keys,
|
|
|
|
}
|
|
|
|
.render()?,
|
|
|
|
)
|
|
|
|
.into_response())
|
|
|
|
}
|
2025-02-26 13:10:45 -08:00
|
|
|
|
|
|
|
async fn channels_page(
|
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
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 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<Channel>,
|
|
|
|
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<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?;
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
let channel_id = Uuid::now_v7();
|
2025-02-26 13:10:45 -08:00
|
|
|
let channel = match form_body.channel_type.as_str() {
|
2025-02-26 13:10:27 -08:00
|
|
|
CHANNEL_BACKEND_EMAIL => db_conn
|
|
|
|
.interact::<_, Result<Channel, AppError>>(move |conn| {
|
|
|
|
Ok(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()?,
|
2025-02-26 13:10:45 -08:00
|
|
|
_ => {
|
|
|
|
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<Settings>,
|
|
|
|
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 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");
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
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,
|
2025-02-26 13:10:45 -08:00
|
|
|
}
|
2025-02-26 13:10:27 -08:00
|
|
|
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())
|
|
|
|
}
|
2025-02-26 13:10:45 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[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 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())
|
|
|
|
}
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
/**
|
|
|
|
* 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(
|
|
|
|
conn: &mut PgConnection,
|
|
|
|
team_id: Uuid,
|
|
|
|
channel_id: 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),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-26 13:10:45 -08:00
|
|
|
#[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,
|
2025-02-26 13:10:43 -08:00
|
|
|
State(mailer): State<Mailer>,
|
2025-02-26 13:10:45 -08:00
|
|
|
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(&Uniform::try_from(0..9).unwrap())
|
2025-02-26 13:10:27 -08:00
|
|
|
.take(VERIFICATION_CODE_LEN)
|
2025-02-26 13:10:45 -08:00
|
|
|
.map(|n| n.to_string())
|
|
|
|
.collect();
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
{
|
|
|
|
let verification_code = verification_code.clone();
|
|
|
|
let recipient = form_body.recipient.clone();
|
|
|
|
let channel_id = channel_id.clone();
|
|
|
|
let team_id = team_id.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 = 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!(
|
|
|
|
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
|
|
|
|
num_rows
|
|
|
|
)
|
|
|
|
.into());
|
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
})
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?;
|
2025-02-26 13:10:45 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
tracing::debug!(
|
|
|
|
"Email verification code for {} is: {}",
|
|
|
|
form_body.recipient,
|
|
|
|
verification_code
|
|
|
|
);
|
|
|
|
tracing::info!(
|
|
|
|
"Sending email verification code to: {}",
|
|
|
|
form_body.recipient
|
|
|
|
);
|
2025-02-26 13:10:43 -08:00
|
|
|
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?;
|
2025-02-26 13:10:45 -08:00
|
|
|
|
|
|
|
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<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?;
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
if form_body.code.len() != VERIFICATION_CODE_LEN {
|
|
|
|
return Err(AppError::BadRequestError(format!(
|
|
|
|
"Verification code must be {} characters long.",
|
|
|
|
VERIFICATION_CODE_LEN
|
|
|
|
)));
|
2025-02-26 13:10:45 -08:00
|
|
|
}
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
{
|
|
|
|
let channel_id = channel_id.clone();
|
|
|
|
let team_id = team_id.clone();
|
|
|
|
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(),
|
|
|
|
));
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
};
|
|
|
|
update(channels::table.filter(Channel::with_id(channel_id)))
|
|
|
|
.set(channels::backend_config.eq(Into::<BackendConfig>::into(new_config)))
|
|
|
|
.execute(conn)?;
|
|
|
|
Ok(())
|
|
|
|
})
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?;
|
|
|
|
};
|
|
|
|
|
2025-02-26 13:10:45 -08:00
|
|
|
Ok(Redirect::to(&format!(
|
|
|
|
"{}/teams/{}/channels/{}",
|
|
|
|
base_path,
|
|
|
|
team_id.simple(),
|
|
|
|
channel_id.simple()
|
|
|
|
)))
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn project_page(
|
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(db_conn): DbConn,
|
|
|
|
Path((team_id, project_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 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<Uuid> = 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<Uuid>,
|
|
|
|
nav_state: NavState,
|
|
|
|
project: Project,
|
|
|
|
team_channels: Vec<Channel>,
|
|
|
|
}
|
|
|
|
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<Uuid>,
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn update_enabled_channels(
|
|
|
|
State(Settings { base_path, .. }): State<Settings>,
|
|
|
|
DbConn(db_conn): DbConn,
|
|
|
|
Path((team_id, project_id)): Path<(Uuid, Uuid)>,
|
|
|
|
CurrentUser(current_user): CurrentUser,
|
|
|
|
Form(form_body): Form<UpdateEnabledChannelsFormBody>,
|
|
|
|
) -> 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 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())
|
|
|
|
}
|