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

259 lines
8.5 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::{self, ChannelSelection},
channels::Channel,
csrf::generate_csrf_token,
guards,
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_PROJECTS},
projects::{self, Project},
settings::Settings,
users::CurrentUser,
watchdogs::{self, Watchdog},
};
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,
frontend_host,
..
}): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
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,
breadcrumbs: BreadcrumbTrail,
csrf_token: String,
frontend_host: String,
keys: Vec<ApiKey>,
navbar: Navbar,
projects: Vec<Project>,
}
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,
frontend_host,
keys: api_keys,
navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_PROJECTS)
.build(),
projects,
}
.render()?,
)
.into_response())
}
async fn project_page(
State(Settings {
base_path,
frontend_host,
..
}): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
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, maybe_watchdog, enabled_channel_ids, team_channels) = db_conn
.interact(move |conn| -> Result<_, AppError> {
// Queried together to save a round trip to the database.
let (project, maybe_watchdog) = projects::table
.left_outer_join(watchdogs::table)
.select(<(Project, Option<Watchdog>)>::as_select())
.filter(Project::with_id(&project_id))
.filter(Project::with_team(&team_id))
.first(conn)
.optional()
.context("failed to load project")?
.ok_or(AppError::NotFound(
"Project with that team and ID not found.".to_string(),
))?;
let enabled_channel_ids: HashSet<Uuid> = project
.selected_channels()
.load(conn)
.context("failed to load selected channels")?
.iter()
.map(|channel| channel.id)
.collect();
let team_channels = Channel::all()
.filter(Channel::with_team(&team_id))
.load(conn)
.context("failed to load team channels")?;
Ok((project, maybe_watchdog, enabled_channel_ids, team_channels))
})
.await
.unwrap()?;
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<Uuid>,
frontend_host: String,
navbar: Navbar,
project: Project,
team_channels: Vec<Channel>,
watchdog: Option<Watchdog>,
}
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,
frontend_host,
project,
navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_PROJECTS)
.build(),
team_channels,
watchdog: maybe_watchdog,
}
.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::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::dsl::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::dsl::project_id.eq(&project.id),
channel_selections::dsl::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())
}