shoutdotdev/src/projects_router.rs

242 lines
7.4 KiB
Rust

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_state::{Breadcrumb, NavState},
projects::Project,
schema::channel_selections,
settings::Settings,
users::CurrentUser,
};
pub fn new_router() -> Router<AppState> {
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<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?;
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<String> {
Ok(crate::api_keys::compact_uuid(id))
}
pub fn redact(value: &str) -> askama::Result<String> {
Ok(format!(
"********{}",
&value[value.char_indices().nth_back(3).unwrap().0..]
))
}
}
#[derive(Template)]
#[template(path = "projects.html")]
struct ResponseTemplate {
base_path: String,
csrf_token: String,
keys: Vec<ApiKey>,
nav_state: NavState,
projects: Vec<Project>,
}
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_slug(Breadcrumb {
href: "projects".to_string(),
label: "Projects".to_string(),
})
.set_navbar_active_item("projects");
Ok(Html(
ResponseTemplate {
base_path,
csrf_token,
nav_state,
projects,
keys: api_keys,
}
.render()?,
)
.into_response())
}
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 = 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::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_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?;
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?;
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::NotFoundError(
"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())
}