misc back-end cleanup

This commit is contained in:
Brent Schroeter 2025-02-26 13:10:48 -08:00
parent 4887dca3bc
commit 7b6a84f011
18 changed files with 366 additions and 301 deletions

12
Cargo.lock generated
View file

@ -227,6 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [ dependencies = [
"axum-core 0.5.0", "axum-core 0.5.0",
"axum-macros",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -317,6 +318,17 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "axum-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.74"

View file

@ -27,7 +27,7 @@ tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "fs", "trace", "tracing"] } tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "fs", "trace", "tracing"] }
tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] } tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] }
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] } deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
axum = "0.8.1" axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.0", features = ["cookie", "typed-header"] } axum-extra = { version = "0.10.0", features = ["cookie", "typed-header"] }
chrono = { version = "0.4.39", features = ["serde"] } chrono = { version = "0.4.39", features = ["serde"] }
base64 = "0.22.1" base64 = "0.22.1"

View file

@ -8,9 +8,9 @@ CREATE INDEX ON users (uid);
CREATE TABLE IF NOT EXISTS csrf_tokens ( CREATE TABLE IF NOT EXISTS csrf_tokens (
id UUID NOT NULL PRIMARY KEY, id UUID NOT NULL PRIMARY KEY,
user_id UUID REFERENCES users(id), user_id UUID REFERENCES users(id),
expires_at TIMESTAMPTZ NOT NULL created_at TIMESTAMPTZ NOT NULL
); );
CREATE INDEX ON csrf_tokens (expires_at); CREATE INDEX ON csrf_tokens (created_at);
CREATE TABLE teams ( CREATE TABLE teams (
id UUID NOT NULL PRIMARY KEY, id UUID NOT NULL PRIMARY KEY,

View file

@ -1,4 +1,8 @@
CREATE TABLE browser_sessions ( CREATE TABLE browser_sessions (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
serialized TEXT NOT NULL serialized TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL
); );
CREATE INDEX ON browser_sessions (last_seen_at);
CREATE INDEX ON browser_sessions (created_at);

View file

@ -1,12 +1,11 @@
use anyhow::Result; use deadpool_diesel::postgres::Connection;
use deadpool_diesel::postgres::Pool;
use diesel::prelude::*; use diesel::prelude::*;
use uuid::Uuid; use uuid::Uuid;
use crate::{app_error::AppError, models::Team, schema}; use crate::{app_error::AppError, schema::api_keys::dsl::*, teams::Team};
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::api_keys)] #[diesel(table_name = crate::schema::api_keys)]
#[diesel(belongs_to(Team))] #[diesel(belongs_to(Team))]
pub struct ApiKey { pub struct ApiKey {
pub id: Uuid, pub id: Uuid,
@ -14,19 +13,20 @@ pub struct ApiKey {
} }
impl ApiKey { impl ApiKey {
pub async fn generate_for_team(db_pool: &Pool, team_id: Uuid) -> Result<Self, AppError> { pub async fn generate_for_team(
let id = Uuid::new_v4(); db_conn: &Connection,
let api_key = db_pool key_team_id: Uuid,
.get() ) -> Result<Self, AppError> {
.await? let api_key = Self {
id: Uuid::new_v4(),
team_id: key_team_id,
};
let api_key_copy = api_key.clone();
db_conn
.interact(move |conn| { .interact(move |conn| {
diesel::insert_into(schema::api_keys::table) diesel::insert_into(api_keys)
.values(( .values(api_key_copy)
schema::api_keys::id.eq(id), .execute(conn)
schema::api_keys::team_id.eq(team_id),
))
.returning(ApiKey::as_select())
.get_result(conn)
}) })
.await .await
.unwrap()?; .unwrap()?;

View file

@ -1,8 +1,11 @@
use axum::extract::FromRef; use axum::{
use deadpool_diesel::postgres::Pool; extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use deadpool_diesel::postgres::{Connection, Pool};
use oauth2::basic::BasicClient; use oauth2::basic::BasicClient;
use crate::{sessions::PgStore, settings::Settings}; use crate::{app_error::AppError, sessions::PgStore, settings::Settings};
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct AppState { pub(crate) struct AppState {
@ -17,3 +20,14 @@ impl FromRef<AppState> for PgStore {
state.session_store.clone() state.session_store.clone()
} }
} }
pub struct DbConn(pub Connection);
impl FromRequestParts<AppState> for DbConn {
type Rejection = AppError;
async fn from_request_parts(_: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let conn = state.db_pool.get().await?;
Ok(Self(conn))
}
}

View file

@ -1,60 +1,84 @@
use anyhow::{Context, Result}; use chrono::{DateTime, TimeDelta, Utc};
use chrono::{TimeDelta, Utc}; use deadpool_diesel::postgres::Connection;
use deadpool_diesel::postgres::Pool; use diesel::{
use diesel::prelude::*; dsl::{AsSelect, Eq, Gt, IsNotDistinctFrom, Select},
pg::Pg,
prelude::*,
};
use uuid::Uuid; use uuid::Uuid;
use crate::{app_error::AppError, models::CsrfToken, schema}; use crate::{app_error::AppError, schema::csrf_tokens::dsl::*};
const TOKEN_PREFIX: &'static str = "csrf__"; const TOKEN_PREFIX: &'static str = "csrf-";
const TTL_SEC: i64 = 60 * 60 * 24 * 7;
pub async fn generate_csrf_token_for_user( #[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
db_pool: &Pool, #[diesel(table_name = crate::schema::csrf_tokens)]
uid: Option<Uuid>, #[diesel(check_for_backend(Pg))]
pub struct CsrfToken {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
impl CsrfToken {
fn all() -> Select<csrf_tokens, AsSelect<CsrfToken, Pg>> {
csrf_tokens.select(Self::as_select())
}
pub fn is_not_expired() -> Gt<created_at, DateTime<Utc>> {
let ttl = TimeDelta::hours(24);
let min_created_at: DateTime<Utc> = Utc::now() - ttl;
created_at.gt(min_created_at)
}
pub fn with_user_id(token_user_id: Option<Uuid>) -> IsNotDistinctFrom<user_id, Option<Uuid>> {
user_id.is_not_distinct_from(token_user_id)
}
pub fn with_token_id(token_id: Uuid) -> Eq<id, Uuid> {
id.eq(token_id)
}
}
pub async fn generate_csrf_token(
db_conn: &Connection,
with_user_id: Option<Uuid>,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let id = Uuid::new_v4(); let token_id = Uuid::new_v4();
let expires_at = db_conn
Utc::now() + TimeDelta::new(TTL_SEC, 0).context("Failed to generate TimeDelta")?;
db_pool
.get()
.await?
.interact(move |conn| { .interact(move |conn| {
diesel::insert_into(schema::csrf_tokens::table) diesel::insert_into(csrf_tokens)
.values(( .values((
schema::csrf_tokens::id.eq(id), id.eq(token_id),
schema::csrf_tokens::user_id.eq(uid), user_id.eq(with_user_id),
schema::csrf_tokens::expires_at.eq(expires_at), created_at.eq(diesel::dsl::now),
)) ))
.execute(conn) .execute(conn)
}) })
.await .await
.unwrap()?; .unwrap()?;
Ok(format!("{}{}", TOKEN_PREFIX, id.hyphenated().to_string())) Ok(format!("{}{}", TOKEN_PREFIX, token_id.simple().to_string()))
} }
pub async fn validate_csrf_token_for_user( pub async fn validate_csrf_token(
db_pool: &Pool, db_conn: &Connection,
token: &str, token: &str,
uid: Option<Uuid>, with_user_id: Option<Uuid>,
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
let id = match Uuid::try_parse(&token[TOKEN_PREFIX.len()..]) { let token_id = match Uuid::try_parse(&token[TOKEN_PREFIX.len()..]) {
Ok(id) => id, Ok(token_id) => token_id,
Err(_) => return Ok(false), Err(_) => return Ok(false),
}; };
let row = db_pool Ok(db_conn
.get()
.await?
.interact(move |conn| { .interact(move |conn| {
schema::csrf_tokens::table CsrfToken::all()
.select(CsrfToken::as_select()) .filter(CsrfToken::with_token_id(token_id))
.filter(schema::csrf_tokens::id.eq(id)) .filter(CsrfToken::with_user_id(with_user_id))
.filter(schema::csrf_tokens::expires_at.gt(Utc::now())) .filter(CsrfToken::is_not_expired())
.filter(schema::csrf_tokens::user_id.is_not_distinct_from(uid))
.first(conn) .first(conn)
.optional() .optional()
}) })
.await .await
.unwrap()?; .unwrap()?
Ok(row.is_some()) .is_some())
} }

45
src/guards.rs Normal file
View file

@ -0,0 +1,45 @@
macro_rules! require_team_membership {
($current_user:expr, $team_id:expr, $db_conn:expr) => {{
let current_user_id = $current_user.id.clone();
match $db_conn
.interact(move |conn| {
crate::team_memberships::TeamMembership::all()
.filter(crate::team_memberships::TeamMembership::with_user_id(
current_user_id,
))
.filter(crate::team_memberships::TeamMembership::with_team_id(
$team_id,
))
.first(conn)
.optional()
})
.await
.unwrap()?
{
Some((team, _)) => team,
None => {
return Ok((
axum::http::StatusCode::FORBIDDEN,
"not a member of requested team".to_string(),
)
.into_response());
}
}
}};
}
pub(crate) use require_team_membership;
macro_rules! require_valid_csrf_token {
($csrf_token:expr, $current_user:expr, $db_conn:expr) => {{
if !crate::csrf::validate_csrf_token(&$db_conn, &$csrf_token, Some($current_user.id))
.await?
{
return Ok((
axum::http::StatusCode::FORBIDDEN,
"invalid CSRF token".to_string(),
)
.into_response());
}
}};
}
pub(crate) use require_valid_csrf_token;

View file

@ -3,20 +3,19 @@ mod app_error;
mod app_state; mod app_state;
mod auth; mod auth;
mod csrf; mod csrf;
mod models; mod guards;
mod projects; mod projects;
mod router; mod router;
mod schema; mod schema;
mod sessions; mod sessions;
mod settings; mod settings;
mod team_memberships;
mod teams;
mod users; mod users;
use app_state::AppState;
use router::new_router;
use sessions::PgStore;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use crate::settings::Settings; use crate::{app_state::AppState, router::new_router, sessions::PgStore, settings::Settings};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {

View file

@ -1,42 +1 @@
use chrono::{offset::Utc, DateTime};
use diesel::{pg::Pg, prelude::*};
use uuid::Uuid;
use crate::schema;
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::teams)]
#[diesel(check_for_backend(Pg))]
pub struct Team {
pub id: Uuid,
pub name: String,
}
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::team_memberships)]
#[diesel(belongs_to(Team))]
#[diesel(belongs_to(crate::users::User))]
#[diesel(primary_key(team_id, user_id))]
#[diesel(check_for_backend(Pg))]
pub struct TeamMembership {
pub team_id: Uuid,
pub user_id: Uuid,
pub roles: Vec<Option<String>>,
}
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = schema::browser_sessions)]
#[diesel(check_for_backend(Pg))]
pub struct BrowserSession {
pub id: String,
pub serialized: String,
}
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = schema::csrf_tokens)]
#[diesel(check_for_backend(Pg))]
pub struct CsrfToken {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub expires_at: DateTime<Utc>,
}

View file

@ -1,7 +1,14 @@
use diesel::prelude::*; use diesel::{
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
};
use uuid::Uuid; use uuid::Uuid;
use crate::{models::Team, schema}; use crate::{
schema::{self, projects::dsl::*},
teams::Team,
};
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::projects)] #[diesel(table_name = schema::projects)]
@ -11,3 +18,11 @@ pub struct Project {
pub team_id: Uuid, pub team_id: Uuid,
pub name: String, pub name: String,
} }
impl Project {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Project, Pg> = Project::as_select();
projects.select(select)
}
}

View file

@ -1,13 +1,11 @@
use anyhow::anyhow;
use askama_axum::Template; use askama_axum::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::status::StatusCode,
response::{Html, IntoResponse, Redirect}, response::{Html, IntoResponse, Redirect},
routing::{get, post}, routing::{get, post},
Form, Router, Form, Router,
}; };
use diesel::{dsl::insert_into, prelude::*, result::Error::NotFound}; use diesel::{dsl::insert_into, prelude::*};
use serde::Deserialize; use serde::Deserialize;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{ use tower_http::{
@ -20,12 +18,15 @@ use uuid::Uuid;
use crate::{ use crate::{
api_keys::ApiKey, api_keys::ApiKey,
app_error::AppError, app_error::AppError,
app_state::AppState, app_state::{AppState, DbConn},
auth::{self, AuthInfo}, auth,
csrf::{generate_csrf_token_for_user, validate_csrf_token_for_user}, csrf::generate_csrf_token,
models::{Team, TeamMembership}, guards,
projects::Project, projects::Project,
schema, schema,
settings::Settings,
team_memberships::TeamMembership,
teams::Team,
users::{CurrentUser, User}, users::{CurrentUser, User},
}; };
@ -59,14 +60,12 @@ async fn landing_page(State(state): State<AppState>) -> impl IntoResponse {
} }
async fn teams_page( async fn teams_page(
State(state): State<AppState>, State(Settings { base_path, .. }): State<Settings>,
DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let current_user_id = current_user.id.clone(); let current_user_id = current_user.id.clone();
let teams_of_current_user = state let teams_of_current_user = conn
.db_pool
.get()
.await?
.interact(move |conn| { .interact(move |conn| {
schema::team_memberships::table schema::team_memberships::table
.inner_join(schema::teams::table) .inner_join(schema::teams::table)
@ -75,8 +74,7 @@ async fn teams_page(
.load(conn) .load(conn)
}) })
.await .await
.unwrap() .unwrap()?;
.unwrap();
#[derive(Template)] #[derive(Template)]
#[template(path = "teams.html")] #[template(path = "teams.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -86,13 +84,12 @@ async fn teams_page(
} }
Ok(Html( Ok(Html(
ResponseTemplate { ResponseTemplate {
base_path,
current_user, current_user,
base_path: state.settings.base_path,
teams: teams_of_current_user, teams: teams_of_current_user,
} }
.render()?, .render()?,
) ))
.into_response())
} }
async fn team_page(State(state): State<AppState>, Path(team_id): Path<Uuid>) -> impl IntoResponse { async fn team_page(State(state): State<AppState>, Path(team_id): Path<Uuid>) -> impl IntoResponse {
@ -108,76 +105,30 @@ struct PostNewApiKeyForm {
} }
async fn post_new_api_key( async fn post_new_api_key(
State(state): State<AppState>, State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>, Path(team_id): Path<Uuid>,
user_info: AuthInfo, CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewApiKeyForm>, Form(form): Form<PostNewApiKeyForm>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let current_uid = user_info.sub.clone(); guards::require_valid_csrf_token!(form.csrf_token, current_user, db_conn);
let current_user = state let team = guards::require_team_membership!(current_user, team_id, db_conn);
.db_pool
.get() ApiKey::generate_for_team(&db_conn, team.id.clone()).await?;
.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!( Ok(Redirect::to(&format!(
"{}/teams/{}/projects", "{}/teams/{}/projects",
state.settings.base_path, base_path,
team_membership.team_id.hyphenated().to_string() team.id.hyphenated().to_string()
)) ))
.into_response()) .into_response())
} }
async fn new_team_page( async fn new_team_page(
State(state): State<AppState>, State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let csrf_token = let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?;
#[derive(Template)] #[derive(Template)]
#[template(path = "new-team.html")] #[template(path = "new-team.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -187,9 +138,9 @@ async fn new_team_page(
} }
Ok(Html( Ok(Html(
ResponseTemplate { ResponseTemplate {
base_path,
csrf_token, csrf_token,
current_user, current_user,
base_path: state.settings.base_path,
} }
.render()?, .render()?,
)) ))
@ -202,33 +153,13 @@ struct PostNewTeamForm {
} }
async fn post_new_team( async fn post_new_team(
State(state): State<AppState>, DbConn(db_conn): DbConn,
user_info: AuthInfo, State(Settings { base_path, .. }): State<Settings>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewTeamForm>, Form(form): Form<PostNewTeamForm>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let current_uid = user_info.sub.clone(); guards::require_valid_csrf_token!(form.csrf_token, current_user, db_conn);
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_id = Uuid::now_v7();
let team = Team { let team = Team {
id: team_id.clone(), id: team_id.clone(),
@ -239,10 +170,7 @@ async fn post_new_team(
user_id: current_user.id, user_id: current_user.id,
roles: vec![Some("OWNER".to_string())], roles: vec![Some("OWNER".to_string())],
}; };
state db_conn
.db_pool
.get()
.await?
.interact(move |conn| { .interact(move |conn| {
conn.transaction(move |conn| { conn.transaction(move |conn| {
insert_into(schema::teams::table) insert_into(schema::teams::table)
@ -257,50 +185,20 @@ async fn post_new_team(
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
ApiKey::generate_for_team(&state.db_pool, team_id.clone()).await?; ApiKey::generate_for_team(&db_conn, team_id.clone()).await?;
Ok(Redirect::to(&format!( Ok(Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id)).into_response())
"{}/teams/{}/projects",
state.settings.base_path, team_id
)))
} }
async fn projects_page( async fn projects_page(
State(state): State<AppState>, State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>, Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let current_user_id = current_user.id.clone(); let team = guards::require_team_membership!(current_user, team_id, db_conn);
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 team_id = team.id.clone();
let api_keys = state let api_keys = db_conn
.db_pool
.get()
.await?
.interact(move |conn| { .interact(move |conn| {
schema::api_keys::table schema::api_keys::table
.filter(schema::api_keys::team_id.eq(team_id)) .filter(schema::api_keys::team_id.eq(team_id))
@ -308,8 +206,7 @@ async fn projects_page(
.load(conn) .load(conn)
}) })
.await .await
.unwrap() .unwrap()?;
.unwrap();
#[derive(Template)] #[derive(Template)]
#[template(path = "projects.html")] #[template(path = "projects.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -320,14 +217,13 @@ async fn projects_page(
team: Team, team: Team,
current_user: User, current_user: User,
} }
let csrf_token = let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?;
generate_csrf_token_for_user(&state.db_pool, Some(current_user.id.clone())).await?;
Ok(Html( Ok(Html(
ResponseTemplate { ResponseTemplate {
base_path,
csrf_token, csrf_token,
current_user, current_user,
team, team,
base_path: state.settings.base_path,
keys: api_keys, keys: api_keys,
projects: vec![], projects: vec![],
} }

View file

@ -11,6 +11,8 @@ diesel::table! {
browser_sessions (id) { browser_sessions (id) {
id -> Text, id -> Text,
serialized -> Text, serialized -> Text,
created_at -> Timestamptz,
last_seen_at -> Timestamptz,
} }
} }
@ -18,7 +20,7 @@ diesel::table! {
csrf_tokens (id) { csrf_tokens (id) {
id -> Uuid, id -> Uuid,
user_id -> Nullable<Uuid>, user_id -> Nullable<Uuid>,
expires_at -> Timestamptz, created_at -> Timestamptz,
} }
} }

View file

@ -1,12 +1,21 @@
use anyhow::Result; use anyhow::Result;
use async_session::{async_trait, Session, SessionStore}; use async_session::{async_trait, Session, SessionStore};
use diesel::prelude::*; use chrono::{DateTime, TimeDelta, Utc};
use diesel::{pg::Pg, prelude::*, upsert::excluded};
use crate::{models::BrowserSession, schema}; use crate::schema::browser_sessions::dsl::*;
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::browser_sessions)]
#[diesel(check_for_backend(Pg))]
pub struct BrowserSession {
pub id: String,
pub serialized: String,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct PgStore { pub struct PgStore {
// TODO: reference instead of clone
pool: deadpool_diesel::postgres::Pool, pool: deadpool_diesel::postgres::Pool,
} }
@ -18,7 +27,7 @@ impl PgStore {
impl std::fmt::Debug for PgStore { impl std::fmt::Debug for PgStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PgStore {{ pool }}")?; write!(f, "PgStore")?;
Ok(()).into() Ok(()).into()
} }
} }
@ -26,39 +35,47 @@ impl std::fmt::Debug for PgStore {
#[async_trait] #[async_trait]
impl SessionStore for PgStore { impl SessionStore for PgStore {
async fn load_session(&self, cookie_value: String) -> Result<Option<Session>> { async fn load_session(&self, cookie_value: String) -> Result<Option<Session>> {
let conn = self.pool.get().await?;
let session_id = Session::id_from_cookie_value(&cookie_value)?; let session_id = Session::id_from_cookie_value(&cookie_value)?;
let rows = conn let timestamp_stale = Utc::now() - TimeDelta::days(7);
let conn = self.pool.get().await?;
let row = conn
.interact(move |conn| { .interact(move |conn| {
schema::browser_sessions::table // Drop all sessions without recent activity
.filter(schema::browser_sessions::id.eq(session_id)) diesel::delete(browser_sessions.filter(last_seen_at.lt(timestamp_stale)))
.select(BrowserSession::as_select()) .execute(conn)?;
.load(conn) diesel::update(browser_sessions.filter(id.eq(session_id)))
.set(last_seen_at.eq(diesel::dsl::now))
.returning(BrowserSession::as_returning())
.get_result(conn)
.optional()
}) })
.await .await
.unwrap()?; .unwrap()?;
if rows.len() == 0 { Ok(match row {
Ok(None) Some(session) => Some(serde_json::from_str::<Session>(
} else { session.serialized.as_str(),
Ok(Some(serde_json::from_str::<Session>( )?),
rows[0].serialized.as_str(), None => None,
)?)) })
}
} }
async fn store_session(&self, session: Session) -> Result<Option<String>> { async fn store_session(&self, session: Session) -> Result<Option<String>> {
let serialized = serde_json::to_string(&session)?; let serialized_data = serde_json::to_string(&session)?;
let conn = self.pool.get().await?;
let session_id = session.id().to_string(); let session_id = session.id().to_string();
let conn = self.pool.get().await?;
conn.interact(move |conn| { conn.interact(move |conn| {
diesel::insert_into(schema::browser_sessions::table) diesel::insert_into(browser_sessions)
.values(( .values((
schema::browser_sessions::id.eq(session_id), id.eq(session_id),
schema::browser_sessions::serialized.eq(serialized.clone()), serialized.eq(serialized_data),
last_seen_at.eq(diesel::dsl::now),
)) ))
.on_conflict(schema::browser_sessions::id) .on_conflict(id)
.do_update() .do_update()
.set(schema::browser_sessions::serialized.eq(serialized.clone())) .set((
serialized.eq(excluded(serialized)),
last_seen_at.eq(excluded(last_seen_at)),
))
.execute(conn) .execute(conn)
}) })
.await .await
@ -70,11 +87,7 @@ impl SessionStore for PgStore {
async fn destroy_session(&self, session: Session) -> Result<()> { async fn destroy_session(&self, session: Session) -> Result<()> {
let conn = self.pool.get().await?; let conn = self.pool.get().await?;
conn.interact(move |conn| { conn.interact(move |conn| {
diesel::delete( diesel::delete(browser_sessions.filter(id.eq(session.id().to_string()))).execute(conn)
schema::browser_sessions::table
.filter(schema::browser_sessions::id.eq(session.id().to_string())),
)
.execute(conn)
}) })
.await .await
.unwrap()?; .unwrap()?;
@ -83,7 +96,7 @@ impl SessionStore for PgStore {
async fn clear_store(&self) -> Result<()> { async fn clear_store(&self) -> Result<()> {
let conn = self.pool.get().await?; let conn = self.pool.get().await?;
conn.interact(move |conn| diesel::delete(schema::browser_sessions::table).execute(conn)) conn.interact(move |conn| diesel::delete(browser_sessions).execute(conn))
.await .await
.unwrap()?; .unwrap()?;
Ok(()) Ok(())

View file

@ -1,7 +1,10 @@
use axum::extract::FromRef;
use config::{Config, ConfigError, Environment}; use config::{Config, ConfigError, Environment};
use dotenvy::dotenv; use dotenvy::dotenv;
use serde::Deserialize; use serde::Deserialize;
use crate::app_state::AppState;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct Settings { pub struct Settings {
#[serde(default)] #[serde(default)]
@ -54,3 +57,9 @@ impl Settings {
s.try_deserialize() s.try_deserialize()
} }
} }
impl FromRef<AppState> for Settings {
fn from_ref(state: &AppState) -> Self {
state.settings.clone()
}
}

43
src/team_memberships.rs Normal file
View file

@ -0,0 +1,43 @@
use diesel::{
dsl::{AsSelect, Eq},
pg::Pg,
prelude::*,
};
use uuid::Uuid;
use crate::{
schema::{self, team_memberships::dsl::*},
teams::Team,
users::User,
};
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::team_memberships)]
#[diesel(belongs_to(crate::teams::Team))]
#[diesel(belongs_to(crate::users::User))]
#[diesel(primary_key(team_id, user_id))]
#[diesel(check_for_backend(Pg))]
pub struct TeamMembership {
pub team_id: Uuid,
pub user_id: Uuid,
pub roles: Vec<Option<String>>,
}
impl TeamMembership {
#[diesel::dsl::auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<(Team, User), Pg> = <(Team, User)>::as_select();
team_memberships
.inner_join(schema::teams::table)
.inner_join(schema::users::table)
.select(select)
}
pub fn with_team_id(team_id_value: Uuid) -> Eq<team_id, Uuid> {
team_id.eq(team_id_value)
}
pub fn with_user_id(user_id_value: Uuid) -> Eq<user_id, Uuid> {
user_id.eq(user_id_value)
}
}

22
src/teams.rs Normal file
View file

@ -0,0 +1,22 @@
use diesel::{
dsl::{AsSelect, Select},
pg::Pg,
prelude::*,
};
use uuid::Uuid;
use crate::schema::teams::dsl::*;
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::teams)]
#[diesel(check_for_backend(Pg))]
pub struct Team {
pub id: Uuid,
pub name: String,
}
impl Team {
pub fn all() -> Select<teams, AsSelect<Team, Pg>> {
teams.select(Team::as_select())
}
}

View file

@ -5,20 +5,19 @@ use axum::{
RequestPartsExt, RequestPartsExt,
}; };
use diesel::{ use diesel::{
associations::Identifiable, deserialize::Queryable, dsl::insert_into, pg::Pg, prelude::*, associations::Identifiable,
deserialize::Queryable,
dsl::{insert_into, AsSelect, Eq, Select},
pg::Pg,
prelude::*,
Selectable, Selectable,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{app_error::AppError, app_state::AppState, auth::AuthInfo, schema::users::dsl::*};
app_error::AppError,
app_state::AppState,
auth::AuthInfo,
schema::{self, users},
};
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = schema::users)] #[diesel(table_name = crate::schema::users)]
#[diesel(check_for_backend(Pg))] #[diesel(check_for_backend(Pg))]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
@ -26,6 +25,16 @@ pub struct User {
pub email: String, pub email: String,
} }
impl User {
pub fn all() -> Select<users, AsSelect<User, Pg>> {
users.select(User::as_select())
}
pub fn with_uid(uid_value: &str) -> Eq<uid, &str> {
uid.eq(uid_value)
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CurrentUser(pub User); pub struct CurrentUser(pub User);
@ -46,9 +55,8 @@ impl FromRequestParts<AppState> for CurrentUser {
.await .await
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))? .map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?
.interact(move |conn| { .interact(move |conn| {
let maybe_current_user = users::table let maybe_current_user = User::all()
.filter(users::uid.eq(auth_info.sub.clone())) .filter(User::with_uid(&auth_info.sub))
.select(User::as_select())
.first(conn) .first(conn)
.optional()?; .optional()?;
if let Some(current_user) = maybe_current_user { if let Some(current_user) = maybe_current_user {
@ -59,11 +67,11 @@ impl FromRequestParts<AppState> for CurrentUser {
uid: auth_info.sub, uid: auth_info.sub,
email: auth_info.email, email: auth_info.email,
}; };
insert_into(users::table) insert_into(users)
.values(&new_user) .values(new_user)
.returning(User::as_returning()) .on_conflict(uid)
.on_conflict(users::uid)
.do_nothing() .do_nothing()
.returning(User::as_returning())
.get_result(conn) .get_result(conn)
}) })
.await .await