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

841 lines
27 KiB
Rust
Raw Normal View History

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_http::services::{ServeDir, ServeFile};
2025-02-26 13:10:50 -08:00
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,
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},
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
};
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();
let app = 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")),
)
.with_state(state);
let app = {
if base_path == "" {
app
} else {
Router::new().nest(&base_path, app).fallback_service(
2025-02-26 13:10:50 -08:00
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
)
}
};
app
2025-02-26 13:10:50 -08:00
}
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(),
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> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_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> {
guards::require_valid_csrf_token(&form.csrf_token, &current_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-03-12 23:16:22 -07:00
.interact::<_, Result<(), AppError>>(move |conn| {
conn.transaction::<(), AppError, _>(move |conn| {
2025-02-26 13:10:50 -08:00
insert_into(schema::teams::table)
2025-03-12 23:16:22 -07:00
.values(&team)
2025-02-26 13:10:50 -08:00
.execute(conn)?;
insert_into(schema::team_memberships::table)
2025-03-12 23:16:22 -07:00
.values(&team_membership)
2025-02-26 13:10:50 -08:00
.execute(conn)?;
2025-03-12 23:16:22 -07:00
Ok(())
2025-02-26 13:10:50 -08:00
})
})
.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> {
let team = guards::require_team_membership(&current_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(&current_user, &team_id, &db_conn).await?;
2025-03-12 23:16:22 -07:00
let channels = {
let team_id = team_id.clone();
db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_team(&team_id))
.load(conn)
})
.await
.unwrap()
.context("Failed to load channels list.")?
};
2025-02-26 13:10:45 -08:00
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(&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();
2025-02-26 13:10:45 -08:00
let channel = match form_body.channel_type.as_str() {
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(&current_user, &team_id, &db_conn).await?;
2025-03-12 23:16:22 -07:00
let channel = {
let channel_id = channel_id.clone();
let team_id = team_id.clone();
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,
2025-02-26 13:10:45 -08:00
}
};
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");
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
}
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, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
2025-03-12 23:16:22 -07:00
let updated_rows = {
let channel_id = channel_id.clone();
let team_id = team_id.clone();
db_conn
.interact(move |conn| {
update(
channels::table
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id)),
)
2025-02-26 13:10:45 -08:00
.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)
2025-03-12 23:16:22 -07:00
})
.await
.unwrap()
.context("Failed to load Channel while updating.")?
};
2025-02-26 13:10:45 -08:00
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())
}
/**
* Helper function to query a channel from the database by ID and team, and
* return an appropriate error if no such channel exists.
*/
2025-03-12 23:16:22 -07:00
fn get_channel_by_params<'a>(
conn: &mut PgConnection,
2025-03-12 23:16:22 -07:00
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),
}
}
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, &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(&Uniform::try_from(0..9).unwrap())
.take(VERIFICATION_CODE_LEN)
2025-02-26 13:10:45 -08:00
.map(|n| n.to_string())
.collect();
{
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| {
2025-03-12 23:16:22 -07:00
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()?
});
2025-03-12 23:16:22 -07:00
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.remove(0)?;
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, &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
)));
2025-02-26 13:10:45 -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| {
2025-03-12 23:16:22 -07:00
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
}
};
2025-03-12 23:16:22 -07:00
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(&current_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();
2025-03-12 23:16:22 -07:00
let team_channels = {
let team_id = team.id.clone();
db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_team(&team_id))
.load(conn)
})
.await
.unwrap()
.context("failed to load team channels")?
};
2025-02-26 13:10:45 -08:00
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, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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())
}