use std::collections::HashSet; 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 serde::Deserialize; use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, app_state::{AppState, DbConn}, channel_selections::ChannelSelection, channels::Channel, csrf::generate_csrf_token, guards, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_PROJECTS}, projects::Project, schema::channel_selections, settings::Settings, users::CurrentUser, }; pub fn new_router() -> Router { Router::new() .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), ) } async fn projects_page( State(Settings { base_path, .. }): State, State(navbar_template): 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, projects) = { let team = team.clone(); db_conn .interact(move |conn| { diesel::QueryResult::Ok(( team.api_keys().load(conn)?, Project::all() .filter(Project::with_team(&team.id)) .load(conn)?, )) }) .await .unwrap()? }; mod filters { use uuid::Uuid; pub fn compact_uuid(id: &Uuid) -> askama::Result { Ok(crate::api_keys::compact_uuid(id)) } pub fn redact(value: &str) -> askama::Result { Ok(format!( "********{}", &value[value.char_indices().nth_back(3).unwrap().0..] )) } } #[derive(Template)] #[template(path = "projects.html")] struct ResponseTemplate { base_path: String, breadcrumbs: BreadcrumbTrail, csrf_token: String, keys: Vec, navbar: Navbar, projects: Vec, } let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; 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("Projects", "projects"), base_path, csrf_token, navbar: navbar_template .with_param("team_id", &team.id.simple().to_string()) .with_active_item(NAVBAR_ITEM_PROJECTS) .build(), projects, keys: api_keys, } .render()?, ) .into_response()) } async fn project_page( State(Settings { base_path, .. }): State, State(navbar_template): 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 = db_conn .interact(move |conn| { match Project::all() .filter(Project::with_id(&project_id)) .filter(Project::with_team(&team_id)) .first(conn) { diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound( "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_channels = db_conn .interact(move |conn| { Channel::all() .filter(Channel::with_team(&team_id)) .load(conn) }) .await .unwrap() .context("failed to load team channels")?; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; #[derive(Template)] #[template(path = "project.html")] struct ResponseTemplate { base_path: String, breadcrumbs: BreadcrumbTrail, csrf_token: String, enabled_channel_ids: HashSet, navbar: Navbar, project: Project, team_channels: Vec, } 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("Projects", "projects") .push_slug(&project.name, &project.id.simple().to_string()), base_path, csrf_token, enabled_channel_ids, project, navbar: navbar_template .with_param("team_id", &team.id.simple().to_string()) .with_active_item(NAVBAR_ITEM_PROJECTS) .build(), 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?; db_conn .interact(move |conn| -> Result<(), AppError> { let project = match Project::all() .filter(Project::with_id(&project_id)) .filter(Project::with_team(&team_id)) .first(conn) { diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound( "Project with that team and ID not found.".to_string(), )), other => other .context("failed to load project") .map_err(|err| err.into()), }?; diesel::delete( channel_selections::table .filter(ChannelSelection::with_project(&project.id)) .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 { diesel::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!( "{}/en/teams/{}/projects/{}", base_path, team_id, project_id )) .into_response()) }