238 lines
7.2 KiB
Rust
238 lines
7.2 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(¤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().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(¤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::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, ¤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::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!(
|
||
|
"{}/teams/{}/projects/{}",
|
||
|
base_path, team_id, project_id
|
||
|
))
|
||
|
.into_response())
|
||
|
}
|