use anyhow::anyhow; use askama_axum::Template; use axum::{ extract::{Path, State}, http::status::StatusCode, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; use diesel::{dsl::insert_into, prelude::*, result::Error::NotFound}; 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, auth::{self, AuthInfo}, csrf::{generate_csrf_token_for_user, validate_csrf_token_for_user}, models::{Team, TeamMembership}, projects::Project, schema, users::{CurrentUser, User}, }; 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)) .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(state): State, CurrentUser(current_user): CurrentUser, ) -> Result { let current_user_id = current_user.id.clone(); let teams_of_current_user = state .db_pool .get() .await? .interact(move |conn| { schema::team_memberships::table .inner_join(schema::teams::table) .filter(schema::team_memberships::user_id.eq(current_user_id)) .select(Team::as_select()) .load(conn) }) .await .unwrap() .unwrap(); #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { base_path: String, teams: Vec, current_user: User, } Ok(Html( ResponseTemplate { current_user, base_path: state.settings.base_path, teams: teams_of_current_user, } .render()?, ) .into_response()) } 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(state): State, Path(team_id): Path, user_info: AuthInfo, Form(form): Form, ) -> Result { let current_uid = user_info.sub.clone(); let current_user = state .db_pool .get() .await? .interact(move |conn| { schema::users::table .filter(schema::users::uid.eq(current_uid)) .select(User::as_select()) .first(conn) }) .await .unwrap() // Bubble up panic from callback .map_err(|diesel_err| match diesel_err { NotFound => AppError::InternalServerError(anyhow!( "user not found in database for uid {}", user_info.sub )), _ => AppError::InternalServerError(diesel_err.into()), })?; if !validate_csrf_token_for_user(&state.db_pool, &form.csrf_token, Some(current_user.id)) .await? { return Ok((StatusCode::FORBIDDEN, "invalid CSRF token".to_string()).into_response()); } let team_membership = match state .db_pool .get() .await? .interact(move |conn| { schema::team_memberships::table .filter(schema::team_memberships::team_id.eq(team_id)) .filter(schema::team_memberships::user_id.eq(current_user.id)) .select(TeamMembership::as_select()) .first(conn) .optional() }) .await .unwrap() .unwrap() { Some(team_membership) => team_membership, None => { return Ok(( StatusCode::FORBIDDEN, "not a member of requested team".to_string(), ) .into_response()); } }; ApiKey::generate_for_team(&state.db_pool, team_membership.team_id.clone()).await?; Ok(Redirect::to(&format!( "{}/teams/{}/projects", state.settings.base_path, team_membership.team_id.hyphenated().to_string() )) .into_response()) } async fn new_team_page( State(state): State, CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?; #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { base_path: String, csrf_token: String, current_user: User, } Ok(Html( ResponseTemplate { csrf_token, current_user, base_path: state.settings.base_path, } .render()?, )) } #[derive(Deserialize)] struct PostNewTeamForm { name: String, csrf_token: String, } async fn post_new_team( State(state): State, user_info: AuthInfo, Form(form): Form, ) -> Result { let current_uid = user_info.sub.clone(); let current_user = state .db_pool .get() .await? .interact(move |conn| { schema::users::table .filter(schema::users::uid.eq(current_uid)) .select(User::as_select()) .first(conn) }) .await .unwrap() .unwrap(); if !validate_csrf_token_for_user( &state.db_pool, &form.csrf_token, Some(current_user.id.clone()), ) .await? { return Err(anyhow!("Invalid CSRF token").into()); } 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())], }; state .db_pool .get() .await? .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(&state.db_pool, team_id.clone()).await?; Ok(Redirect::to(&format!( "{}/teams/{}/projects", state.settings.base_path, team_id ))) } async fn projects_page( State(state): State, Path(team_id): Path, CurrentUser(current_user): CurrentUser, ) -> Result { let current_user_id = current_user.id.clone(); let team = match state .db_pool .get() .await? .interact(move |conn| { schema::team_memberships::table .inner_join(schema::teams::table) .filter(schema::team_memberships::user_id.eq(current_user_id)) .filter(schema::teams::id.eq(team_id)) .select(Team::as_select()) .first(conn) .optional() }) .await .unwrap() .unwrap() { Some(team) => team, None => { return Ok(( StatusCode::FORBIDDEN, "not a member of requested team".to_string(), ) .into_response()); } }; let team_id = team.id.clone(); let api_keys = state .db_pool .get() .await? .interact(move |conn| { schema::api_keys::table .filter(schema::api_keys::team_id.eq(team_id)) .select(ApiKey::as_select()) .load(conn) }) .await .unwrap() .unwrap(); #[derive(Template)] #[template(path = "projects.html")] struct ResponseTemplate { base_path: String, csrf_token: String, keys: Vec, projects: Vec, team: Team, current_user: User, } let csrf_token = generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?; Ok(Html( ResponseTemplate { csrf_token, current_user, team, base_path: state.settings.base_path, keys: api_keys, projects: vec![], } .render()?, ) .into_response()) }