use anyhow::Context; use askama_axum::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; use diesel::{dsl::insert_into, prelude::*}; use serde::Deserialize; use tower::ServiceBuilder; use tower_http::{ compression::CompressionLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, app_state::{AppState, DbConn}, auth, csrf::generate_csrf_token, guards, nav_state::{Breadcrumb, NavState}, projects::Project, schema, settings::Settings, team_memberships::TeamMembership, teams::Team, users::CurrentUser, v0_router, }; pub fn new_router(state: AppState) -> Router<()> { let base_path = state.settings.base_path.clone(); Router::new().nest( format!("{}", base_path).as_str(), 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}/new-api-key", post(post_new_api_key)) .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")), ) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new()), ) .with_state(state), ) } async fn landing_page(State(state): State) -> impl IntoResponse { Redirect::to(&format!("{}/teams", state.settings.base_path)) } async fn teams_page( State(Settings { base_path, .. }): State, DbConn(conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let team_memberships_query = current_user.clone().team_memberships(); let teams: Vec = conn .interact(move |conn| team_memberships_query.load(conn)) .await .unwrap() .context("failed to load team memberships") .map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())?; let nav_state = NavState::new() .set_base_path(&base_path) .push_slug(Breadcrumb { href: "teams".to_string(), label: "New Team".to_string(), }) .set_navbar_active_item("teams"); #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { base_path: String, teams: Vec, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, nav_state, teams, } .render()?, )) } async fn team_page(State(state): State, Path(team_id): Path) -> 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( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; ApiKey::generate_for_team(&db_conn, team.id.clone()).await?; Ok(Redirect::to(&format!( "{}/teams/{}/projects", base_path, team.id.hyphenated().to_string() )) .into_response()) } async fn new_team_page( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; 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"); #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { base_path: String, csrf_token: String, nav_state: NavState, } Ok(Html( ResponseTemplate { base_path, csrf_token, nav_state, } .render()?, )) } #[derive(Deserialize)] struct PostNewTeamForm { name: String, csrf_token: String, } async fn post_new_team( DbConn(db_conn): DbConn, State(Settings { base_path, .. }): State, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; 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, roles: vec![Some("OWNER".to_string())], }; db_conn .interact(move |conn| { conn.transaction(move |conn| { insert_into(schema::teams::table) .values(team) .execute(conn)?; insert_into(schema::team_memberships::table) .values(team_membership) .execute(conn)?; diesel::QueryResult::Ok(()) }) }) .await .unwrap() .unwrap(); ApiKey::generate_for_team(&db_conn, team_id.clone()).await?; Ok(Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id)).into_response()) } async fn projects_page( State(Settings { base_path, .. }): 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_query = team.clone().api_keys(); let (api_keys, projects) = db_conn .interact(move |conn| { diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?)) }) .await .unwrap()?; #[derive(Template)] #[template(path = "projects.html")] struct ResponseTemplate { base_path: String, csrf_token: String, keys: Vec, nav_state: NavState, projects: Vec, } 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: "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()) }