1
0
Fork 0
forked from 2sys/shoutdotdev

overarching refactor and cleanup

This commit is contained in:
Brent Schroeter 2025-03-14 13:04:57 -07:00
parent c9912ff332
commit cd63f87f1b
26 changed files with 1207 additions and 1169 deletions

View file

@ -11,6 +11,8 @@ use uuid::Uuid;
use crate::{app_error::AppError, schema::api_keys, teams::Team};
/// A team-scoped application key for authenticating API calls to /say, etc.
/// Does not authorize any administrative functions besides creating projects.
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = api_keys)]
#[diesel(belongs_to(Team))]
@ -46,27 +48,23 @@ impl ApiKey {
}
#[auto_type(no_type_alias)]
pub fn with_id(id: Uuid) -> _ {
pub fn with_id<'a>(id: &'a Uuid) -> _ {
api_keys::id.eq(id)
}
#[auto_type(no_type_alias)]
pub fn with_team(team_id: Uuid) -> _ {
pub fn with_team<'a>(team_id: &'a Uuid) -> _ {
api_keys::team_id.eq(team_id)
}
}
/**
* Encode big-endian bytes of a UUID as URL-safe base64.
*/
/// Encode big-endian bytes of a UUID as URL-safe base64.
pub fn compact_uuid(id: &Uuid) -> String {
URL_SAFE_NO_PAD.encode(id.as_bytes())
}
/**
* Attempt to parse a string as either a standard formatted UUID or a big-endian
* base64 encoding of one.
*/
/// Attempt to parse a string as either a standard formatted UUID or a
/// big-endian base64 encoding of one.
pub fn try_parse_as_uuid(value: &str) -> Result<Uuid> {
if value.len() < 32 {
let bytes: Vec<u8> = URL_SAFE_NO_PAD

View file

@ -9,8 +9,7 @@ pub struct AuthRedirectInfo {
base_path: String,
}
// Use anyhow, define error and enable '?'
// For a simplified example of using anyhow in axum check /examples/anyhow-error-response
/// Custom error type that maps to appropriate HTTP responses.
#[derive(Debug)]
pub enum AppError {
InternalServerError(anyhow::Error),
@ -27,13 +26,13 @@ impl AppError {
}
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
// TODO: customize validation errors formatting
Self::BadRequestError(
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
)
}
}
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
@ -67,8 +66,7 @@ impl IntoResponse for AppError {
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
// `Result<_, AppError>`. That way you don't need to do that manually.
// Easily convert semi-arbitrary errors to InternalServerError
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,

View file

@ -1,3 +1,6 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
@ -5,10 +8,15 @@ use axum::{
use deadpool_diesel::postgres::{Connection, Pool};
use oauth2::basic::BasicClient;
use crate::{app_error::AppError, email::Mailer, sessions::PgStore, settings::Settings};
use crate::{
app_error::AppError,
email::{Mailer, SmtpOptions},
sessions::PgStore,
settings::Settings,
};
#[derive(Clone)]
pub struct AppState {
/// Global app configuration
pub struct App {
pub db_pool: Pool,
pub mailer: Mailer,
pub reqwest_client: reqwest::Client,
@ -17,6 +25,46 @@ pub struct AppState {
pub settings: Settings,
}
impl App {
/// Initialize global application functions based on config values
pub async fn from_settings(settings: Settings) -> Result<Self> {
let database_url = settings.database_url.clone();
let manager =
deadpool_diesel::postgres::Manager::new(database_url, deadpool_diesel::Runtime::Tokio1);
let db_pool = deadpool_diesel::postgres::Pool::builder(manager).build()?;
let session_store = PgStore::new(db_pool.clone());
let reqwest_client = reqwest::ClientBuilder::new().https_only(true).build()?;
let oauth_client = crate::auth::new_oauth_client(&settings)?;
let mailer = if let Some(smtp_settings) = settings.email.smtp.clone() {
Mailer::new_smtp(SmtpOptions {
server: smtp_settings.server,
username: smtp_settings.username,
password: smtp_settings.password,
})?
} else if let Some(postmark_settings) = settings.email.postmark.clone() {
Mailer::new_postmark(postmark_settings.server_token)?
.with_reqwest_client(reqwest_client.clone())
} else {
return Err(anyhow::anyhow!("no email backend settings configured"));
};
Ok(Self {
db_pool,
mailer,
oauth_client,
reqwest_client,
session_store,
settings,
})
}
}
/// Global app configuration, arced for relatively inexpensive clones
pub type AppState = Arc<App>;
/// State extractor for shared reqwest client
#[derive(Clone)]
pub struct ReqwestClient(pub reqwest::Client);
@ -26,6 +74,7 @@ impl FromRef<AppState> for ReqwestClient {
}
}
/// Extractor to automatically obtain a Deadpool database connection
pub struct DbConn(pub Connection);
impl FromRequestParts<AppState> for DbConn {

View file

@ -22,11 +22,12 @@ use crate::{
settings::Settings,
};
const SESSION_KEY_AUTH_CSRF_TOKEN: &'static str = "oauth_csrf_token";
const SESSION_KEY_AUTH_REFRESH_TOKEN: &'static str = "oauth_refresh_token";
const SESSION_KEY_AUTH_INFO: &'static str = "auth";
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_token";
const SESSION_KEY_AUTH_REFRESH_TOKEN: &str = "oauth_refresh_token";
const SESSION_KEY_AUTH_INFO: &str = "auth";
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient, AppError> {
/// Creates a new OAuth2 client to be stored in global application state.
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
Ok(BasicClient::new(
ClientId::new(settings.auth.client_id.clone()),
Some(ClientSecret::new(settings.auth.client_secret.clone())),
@ -43,14 +44,16 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient, AppError> {
))
}
/// Creates a router which can be nested within the higher level app router.
pub fn new_router() -> Router<AppState> {
Router::new()
.route("/login", get(start_login))
.route("/callback", get(login_authorized))
.route("/callback", get(callback))
.route("/logout", get(logout))
}
pub async fn start_login(
/// HTTP get handler for /login
async fn start_login(
State(state): State<AppState>,
State(Settings {
auth: auth_settings,
@ -84,10 +87,11 @@ pub async fn start_login(
.http_only(true)
.path("/"),
);
Ok((jar, Redirect::to(&auth_url.to_string())).into_response())
Ok((jar, Redirect::to(auth_url.as_ref())).into_response())
}
pub async fn logout(
/// HTTP get handler for /logout
async fn logout(
State(Settings {
base_path,
auth: auth_settings,
@ -128,18 +132,14 @@ pub async fn logout(
}
#[derive(Debug, Deserialize)]
pub struct AuthRequestQuery {
struct AuthRequestQuery {
code: String,
state: String, // CSRF token
/// CSRF token
state: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AuthInfo {
pub sub: String,
pub email: String,
}
pub async fn login_authorized(
/// HTTP get handler for /callback
async fn callback(
Query(query): Query<AuthRequestQuery>,
State(state): State<AppState>,
State(Settings {
@ -153,9 +153,7 @@ pub async fn login_authorized(
let mut session = if let Some(session) = session {
session
} else {
return Err(AppError::auth_redirect_from_base_path(
state.settings.base_path,
));
return Err(AppError::auth_redirect_from_base_path(base_path));
};
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
tracing::debug!("oauth csrf token not found on session");
@ -194,6 +192,13 @@ pub async fn login_authorized(
Ok(Redirect::to(&format!("{}/", base_path)))
}
/// Data stored in the visitor's session upon successful authentication.
#[derive(Debug, Deserialize, Serialize)]
pub struct AuthInfo {
pub sub: String,
pub email: String,
}
impl FromRequestParts<AppState> for AuthInfo {
type Rejection = AppError;
@ -214,7 +219,7 @@ impl FromRequestParts<AppState> for AuthInfo {
)?;
Ok(user)
}
// The Span.enter() guard pattern doesn't play nicely async
// The Span.enter() guard pattern doesn't play nicely with async
.instrument(trace_span!("AuthInfo from_request_parts()"))
.await
}

View file

@ -25,12 +25,12 @@ impl ChannelSelection {
}
#[auto_type(no_type_alias)]
pub fn with_channel(channel_id: Uuid) -> _ {
pub fn with_channel<'a>(channel_id: &'a Uuid) -> _ {
channel_selections::channel_id.eq(channel_id)
}
#[auto_type(no_type_alias)]
pub fn with_project(project_id: Uuid) -> _ {
pub fn with_project<'a>(project_id: &'a Uuid) -> _ {
channel_selections::project_id.eq(project_id)
}
}

View file

@ -14,15 +14,13 @@ use uuid::Uuid;
use crate::{schema::channels, teams::Team};
pub const CHANNEL_BACKEND_EMAIL: &'static str = "email";
pub const CHANNEL_BACKEND_SLACK: &'static str = "slack";
pub const CHANNEL_BACKEND_EMAIL: &str = "email";
pub const CHANNEL_BACKEND_SLACK: &str = "slack";
/**
* Represents a target/destination for messages, with the sender configuration
* defined in the backend_config field. A single channel may be attached to
* (in other words, "enabled" or "selected" for) any number of projects within
* the same team.
*/
/// Represents a target/destination for messages, with the sender configuration
/// defined in the backend_config field. A single channel may be attached to
/// (in other words, "enabled" or "selected" for) any number of projects within
/// the same team.
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(belongs_to(Team))]
#[diesel(check_for_backend(Pg))]
@ -57,20 +55,18 @@ impl Channel {
}
}
/**
* Encapsulates any information that needs to be persisted for setting up or
* using a channel's backend (that is, email sender, Slack app, etc.). This
* configuration is encoded to a jsonb column in the database, which determines
* the channel type along with configuration details.
*
* Note: In a previous implementation, channel configuration was handled by
* creating a dedicated table for each channel type and joining them to the
* `channels` table in order to access configuration fields. The jsonb approach
* simplifies database management and lends itself to a cleaner Rust
* implementation in which this enum can be treated as a column type with
* enforcement of data structure invariants handled entirely in the to_sql()
* and from_sql() serialization/deserialization logic.
*/
// Note: In a previous implementation, channel configuration was handled by
// creating a dedicated table for each channel type and joining them to the
// `channels` table in order to access configuration fields. The jsonb approach
// simplifies database management and lends itself to a cleaner Rust
// implementation in which this enum can be treated as a column type with
// enforcement of data structure invariants handled entirely in the to_sql()
// and from_sql() serialization/deserialization logic.
/// Encapsulates any information that needs to be persisted for setting up or
/// using a channel's backend (that is, email sender, Slack app, etc.). This
/// configuration is encoded to a jsonb column in the database, which determines
/// the channel type along with configuration details.
#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)]
#[diesel(sql_type = Jsonb)]
pub enum BackendConfig {
@ -79,7 +75,7 @@ pub enum BackendConfig {
}
impl ToSql<Jsonb, Pg> for BackendConfig {
fn to_sql<'a>(&self, out: &mut Output<'a, '_, Pg>) -> diesel::serialize::Result {
fn to_sql(&self, out: &mut Output<'_, '_, Pg>) -> diesel::serialize::Result {
match self.clone() {
BackendConfig::Email(config) => ToSql::<Jsonb, Pg>::to_sql(
&json!({
@ -142,9 +138,9 @@ impl TryFrom<BackendConfig> for EmailBackendConfig {
}
}
impl Into<BackendConfig> for EmailBackendConfig {
fn into(self) -> BackendConfig {
BackendConfig::Email(self)
impl From<EmailBackendConfig> for BackendConfig {
fn from(value: EmailBackendConfig) -> Self {
Self::Email(value)
}
}
@ -170,8 +166,8 @@ impl TryFrom<BackendConfig> for SlackBackendConfig {
}
}
impl Into<BackendConfig> for SlackBackendConfig {
fn into(self) -> BackendConfig {
BackendConfig::Slack(self)
impl From<SlackBackendConfig> for BackendConfig {
fn from(value: SlackBackendConfig) -> Self {
Self::Slack(value)
}
}

455
src/channels_router.rs Normal file
View file

@ -0,0 +1,455 @@
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 rand::Rng as _;
use regex::Regex;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
app_error::AppError,
app_state::{AppState, DbConn},
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
csrf::generate_csrf_token,
email::{MailSender as _, Mailer},
guards,
nav_state::{Breadcrumb, NavState},
schema::channels,
settings::Settings,
users::CurrentUser,
};
const VERIFICATION_CODE_LEN: usize = 6;
/// Helper function to query a channel from the database by ID and team, and
/// return an appropriate error if no such channel exists.
fn get_channel_by_params<'a>(
conn: &mut PgConnection,
team_id: &'a Uuid,
channel_id: &'a Uuid,
) -> Result<Channel, AppError> {
match Channel::all()
.filter(Channel::with_id(channel_id))
.filter(Channel::with_team(team_id))
.first(conn)
{
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError(
"Channel with that team and ID not found.".to_string(),
)),
diesel::QueryResult::Err(err) => Err(err.into()),
diesel::QueryResult::Ok(channel) => Ok(channel),
}
}
pub fn new_router() -> Router<AppState> {
Router::new()
.route("/teams/{team_id}/channels", get(channels_page))
.route("/teams/{team_id}/channels/{channel_id}", get(channel_page))
.route(
"/teams/{team_id}/channels/{channel_id}/update-channel",
post(update_channel),
)
.route(
"/teams/{team_id}/channels/{channel_id}/update-email-recipient",
post(update_channel_email_recipient),
)
.route(
"/teams/{team_id}/channels/{channel_id}/verify-email",
post(verify_email),
)
.route("/teams/{team_id}/new-channel", post(post_new_channel))
}
async fn channels_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(&current_user, &team_id, &db_conn).await?;
let channels = {
db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_team(&team_id))
.load(conn)
})
.await
.unwrap()
.context("Failed to load channels list.")?
};
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: "channels".to_string(),
label: "Channels".to_string(),
})
.set_navbar_active_item("channels");
#[derive(Template)]
#[template(path = "channels.html")]
struct ResponseTemplate {
base_path: String,
channels: Vec<Channel>,
csrf_token: String,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
channels,
csrf_token,
nav_state,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
struct NewChannelPostFormBody {
csrf_token: String,
channel_type: String,
}
async fn post_new_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<NewChannelPostFormBody>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
let channel_id = Uuid::now_v7();
let channel = match form_body.channel_type.as_str() {
CHANNEL_BACKEND_EMAIL => db_conn
.interact::<_, Result<Channel, AppError>>(move |conn| {
Ok(diesel::insert_into(channels::table)
.values((
channels::id.eq(channel_id),
channels::team_id.eq(team_id),
channels::name.eq("Untitled Email Channel"),
channels::backend_config
.eq(Into::<BackendConfig>::into(EmailBackendConfig::default())),
))
.returning(Channel::as_returning())
.get_result(conn)
.context("Failed to insert new EmailChannel.")?)
})
.await
.unwrap()?,
_ => {
return Err(AppError::BadRequestError(
"Channel type not recognized.".to_string(),
));
}
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team.id.simple(),
channel.id.simple()
)))
}
async fn channel_page(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_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 channel = {
match db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id))
.first(conn)
.optional()
})
.await
.unwrap()?
{
None => {
return Err(AppError::NotFoundError(
"Channel with that team and ID not found".to_string(),
));
}
Some(channel) => channel,
}
};
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: "channels".to_string(),
label: "Channels".to_string(),
})
.push_slug(Breadcrumb {
href: channel.id.simple().to_string(),
label: channel.name.clone(),
})
.set_navbar_active_item("channels");
match channel.backend_config {
BackendConfig::Email(_) => {
#[derive(Template)]
#[template(path = "channel-email.html")]
struct ResponseTemplate {
base_path: String,
channel: Channel,
csrf_token: String,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
channel,
csrf_token,
nav_state,
}
.render()?,
))
}
BackendConfig::Slack(_) => {
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
}
}
}
#[derive(Deserialize)]
struct UpdateChannelFormBody {
csrf_token: String,
name: String,
enable_by_default: Option<String>,
}
async fn update_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelFormBody>,
) -> 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?;
let updated_rows = {
db_conn
.interact(move |conn| {
diesel::update(
channels::table
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id)),
)
.set((
channels::name.eq(form_body.name),
channels::enable_by_default
.eq(form_body.enable_by_default.unwrap_or("false".to_string()) == "true"),
))
.execute(conn)
})
.await
.unwrap()
.context("Failed to load Channel while updating.")?
};
if updated_rows != 1 {
return Err(AppError::NotFoundError(
"Channel with that team and ID not found".to_string(),
));
}
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
))
.into_response())
}
#[derive(Deserialize)]
struct UpdateChannelEmailRecipientFormBody {
// Yes it's a mouthful, but it's only used twice
csrf_token: String,
recipient: String,
}
async fn update_channel_email_recipient(
State(Settings {
base_path,
email: email_settings,
..
}): State<Settings>,
DbConn(db_conn): DbConn,
State(mailer): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
) -> 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?;
if !is_permissible_email(&form_body.recipient) {
return Err(AppError::BadRequestError(
"Unable to validate email address format.".to_string(),
));
}
let verification_code: String = rand::thread_rng()
.sample_iter(&rand::distributions::Uniform::from(0..9))
.take(VERIFICATION_CODE_LEN)
.map(|n| n.to_string())
.collect();
{
let verification_code = verification_code.clone();
let recipient = form_body.recipient.clone();
db_conn
.interact(move |conn| {
// TODO: transaction retries
conn.transaction::<_, AppError, _>(move |conn| {
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
let new_config = BackendConfig::Email(EmailBackendConfig {
recipient,
verification_code,
verification_code_guesses: 0,
..channel.backend_config.try_into()?
});
let num_rows = diesel::update(channels::table.filter(Channel::with_id(&channel.id)))
.set(channels::backend_config.eq(new_config))
.execute(conn)?;
if num_rows != 1 {
return Err(anyhow::anyhow!(
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
num_rows
)
.into());
}
Ok(())
})
})
.await
.unwrap()?;
}
tracing::debug!(
"Email verification code for {} is: {}",
form_body.recipient,
verification_code
);
tracing::info!(
"Sending email verification code to: {}",
form_body.recipient
);
let email = crate::email::Message {
from: email_settings.verification_from,
to: form_body.recipient.parse()?,
subject: "Verify Your Email".to_string(),
text_body: format!("Your email verification code is: {}", verification_code),
};
mailer.send_batch(vec![email]).await.remove(0)?;
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
)))
}
/// Returns true if the email address matches a format recognized as "valid".
/// Not all "legal" email addresses will be accepted, but addresses that are
/// "illegal" and/or could result in unexpected behavior should be rejected.
fn is_permissible_email(address: &str) -> bool {
let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$")
.expect("email validation regex should parse");
re.is_match(address)
}
#[derive(Deserialize)]
struct VerifyEmailFormBody {
csrf_token: String,
code: String,
}
async fn verify_email(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<VerifyEmailFormBody>,
) -> 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?;
if form_body.code.len() != VERIFICATION_CODE_LEN {
return Err(AppError::BadRequestError(format!(
"Verification code must be {} characters long.",
VERIFICATION_CODE_LEN
)));
}
{
let verification_code = form_body.code;
db_conn
.interact(move |conn| {
conn.transaction::<(), AppError, _>(move |conn| {
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
let config: EmailBackendConfig = channel.backend_config.try_into()?;
if config.verified {
return Err(AppError::BadRequestError(
"Channel's email address is already verified.".to_string(),
));
}
const MAX_VERIFICATION_GUESSES: u32 = 100;
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
return Err(AppError::BadRequestError(
"Verification expired.".to_string(),
));
}
let new_config = if config.verification_code == verification_code {
EmailBackendConfig {
verified: true,
verification_code: "".to_string(),
verification_code_guesses: 0,
..config
}
} else {
EmailBackendConfig {
verification_code_guesses: config.verification_code_guesses + 1,
..config
}
};
diesel::update(channels::table.filter(Channel::with_id(&channel_id)))
.set(channels::backend_config.eq(Into::<BackendConfig>::into(new_config)))
.execute(conn)?;
Ok(())
})
})
.await
.unwrap()?;
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
)))
}

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, TimeDelta, Utc};
use deadpool_diesel::postgres::Connection;
use diesel::{
dsl::{AsSelect, Eq, Gt, IsNotDistinctFrom, Select},
dsl::{auto_type, AsSelect, Gt, Select},
pg::Pg,
prelude::*,
};
@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::{app_error::AppError, schema::csrf_tokens::dsl::*};
const TOKEN_PREFIX: &'static str = "csrf-";
const TOKEN_PREFIX: &str = "csrf-";
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::csrf_tokens)]
@ -31,15 +31,18 @@ impl CsrfToken {
created_at.gt(min_created_at)
}
pub fn with_user_id(token_user_id: Option<Uuid>) -> IsNotDistinctFrom<user_id, Option<Uuid>> {
#[auto_type(no_type_alias)]
pub fn with_user_id<'a>(token_user_id: &'a Option<Uuid>) -> _ {
user_id.is_not_distinct_from(token_user_id)
}
pub fn with_token_id(token_id: Uuid) -> Eq<id, Uuid> {
#[auto_type(no_type_alias)]
pub fn with_token_id<'a>(token_id: &'a Uuid) -> _ {
id.eq(token_id)
}
}
/// Convenience function for creating new CSRF token rows in the database.
pub async fn generate_csrf_token(
db_conn: &Connection,
with_user_id: Option<Uuid>,
@ -57,9 +60,10 @@ pub async fn generate_csrf_token(
})
.await
.unwrap()?;
Ok(format!("{}{}", TOKEN_PREFIX, token_id.simple().to_string()))
Ok(format!("{}{}", TOKEN_PREFIX, token_id.simple()))
}
/// Convenience function for validating CSRF tokens against the database.
pub async fn validate_csrf_token(
db_conn: &Connection,
token: &str,
@ -72,8 +76,8 @@ pub async fn validate_csrf_token(
Ok(db_conn
.interact(move |conn| {
CsrfToken::all()
.filter(CsrfToken::with_token_id(token_id))
.filter(CsrfToken::with_user_id(with_user_id))
.filter(CsrfToken::with_token_id(&token_id))
.filter(CsrfToken::with_user_id(&with_user_id))
.filter(CsrfToken::is_not_expired())
.first(conn)
.optional()

View file

@ -1,11 +1,12 @@
use anyhow::{Context, Result};
use axum::extract::FromRef;
use futures::Future;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use serde::{Serialize, Serializer};
use crate::app_state::AppState;
const POSTMARK_EMAIL_BATCH_URL: &'static str = "https://api.postmarkapp.com/email/batch";
const POSTMARK_EMAIL_BATCH_URL: &str = "https://api.postmarkapp.com/email/batch";
#[derive(Clone, Serialize)]
pub struct Message {
@ -21,11 +22,9 @@ pub struct Message {
}
pub trait MailSender: Clone + Sync {
/**
* Attempt to send all messages defined by the input Vec. Send as many as
* possible, returning exactly one Result<()> for each message.
*/
async fn send_batch(&self, emails: Vec<Message>) -> Vec<Result<()>>;
/// Attempt to send all messages defined by the input Vec. Send as many as
/// possible, returning exactly one Result<()> for each message.
fn send_batch(&self, emails: Vec<Message>) -> impl Future<Output = Vec<Result<()>>>;
}
#[derive(Clone, Debug)]
@ -61,7 +60,7 @@ impl MailSender for Mailer {
}
#[derive(Clone, Debug)]
struct SmtpSender {
pub struct SmtpSender {
transport: AsyncSmtpTransport<Tokio1Executor>,
}
@ -104,7 +103,7 @@ fn serialize_mailboxes<S>(t: &lettre::message::Mailboxes, s: S) -> Result<S::Ok,
where
S: Serializer,
{
Ok(s.serialize_str(&t.to_string())?)
s.serialize_str(&t.to_string())
}
impl MailSender for SmtpSender {
@ -131,7 +130,7 @@ impl MailSender for SmtpSender {
}
#[derive(Clone, Debug)]
struct PostmarkSender {
pub struct PostmarkSender {
client: reqwest::Client,
server_token: String,
}
@ -150,14 +149,10 @@ impl PostmarkSender {
}
impl MailSender for PostmarkSender {
/**
* Recursively attempts to send messages, breaking them into smaller and
* smaller batches as needed.
*/
/// Recursively attempts to send messages, breaking them into smaller and
/// smaller batches as needed.
async fn send_batch(&self, mut emails: Vec<Message>) -> Vec<Result<()>> {
/**
* Constructs a Vec with Ok(()) repeated n times.
*/
/// Constructs a Vec with Ok(()) repeated n times.
macro_rules! all_ok {
() => {{
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
@ -168,10 +163,8 @@ impl MailSender for PostmarkSender {
}};
}
/**
* Constructs a Vec with a single specific error, followed by n-1
* generic errors referring back to it.
*/
/// Constructs a Vec with a single specific error, followed by n-1
/// generic errors referring back to it.
macro_rules! cascade_err {
($err:expr) => {{
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
@ -183,15 +176,12 @@ impl MailSender for PostmarkSender {
}};
}
/**
* Recursively splits the email batch in half and tries to send each
* half independently, allowing both to run to completion and then
* returning the first error of the two results, if present.
*
* This is implemented as a macro in order to avoid unstable async
* closures.
*/
/// Recursively splits the email batch in half and tries to send each
/// half independently, allowing both to run to completion and then
/// returning the first error of the two results, if present.
macro_rules! split_and_retry {
// This is implemented as a macro in order to avoid unstable async
// closures.
() => {
if emails.len() < 2 {
tracing::warn!("Postmark send batch cannot be split any further");
@ -213,7 +203,7 @@ impl MailSender for PostmarkSender {
const POSTMARK_MAX_REQUEST_BYTES: usize = 50 * 1000 * 1000;
// TODO: Check email subject and body size against Postmark limits
if emails.len() == 0 {
if emails.is_empty() {
tracing::debug!("no Postmark messages to send");
vec![Ok(())]
} else if emails.len() > POSTMARK_MAX_BATCH_ENTRIES {
@ -248,13 +238,11 @@ impl MailSender for PostmarkSender {
};
if resp.status().is_client_error() && emails.len() > 1 {
split_and_retry!()
} else if let Err(err) = resp.error_for_status() {
cascade_err!(err.into())
} else {
if let Err(err) = resp.error_for_status() {
cascade_err!(err.into())
} else {
tracing::debug!("sent Postmark batch of {} messages", emails.len());
all_ok!()
}
tracing::debug!("sent Postmark batch of {} messages", emails.len());
all_ok!()
}
}
}

View file

@ -12,6 +12,7 @@ use uuid::Uuid;
use crate::schema::{governor_entries, governors};
// Expose built-in Postgres GREATEST() function to Diesel
define_sql_function! {
fn greatest(a: diesel::sql_types::Integer, b: diesel::sql_types::Integer) -> Integer
}
@ -54,27 +55,26 @@ impl Governor {
}
#[auto_type(no_type_alias)]
pub fn with_id(governor_id: Uuid) -> _ {
pub fn with_id<'a>(governor_id: &'a Uuid) -> _ {
governors::id.eq(governor_id)
}
#[auto_type(no_type_alias)]
pub fn with_team(team_id: Uuid) -> _ {
pub fn with_team<'a>(team_id: &'a Uuid) -> _ {
governors::team_id.eq(team_id)
}
#[auto_type(no_type_alias)]
pub fn with_project(project_id: Option<Uuid>) -> _ {
pub fn with_project<'a>(project_id: &'a Option<Uuid>) -> _ {
governors::project_id.is_not_distinct_from(project_id)
}
// TODO: return a custom result enum instead of a Result<Option>, for
// better readability
/**
* Attempt to increment the rolling count. If the governor is not full,
* returns a GovernorEntry which can be used to cancel the operation and
* restore the rolling count. If governor is full, returns None.
*/
/// Attempt to increment the rolling count. If the governor is not full,
/// returns a GovernorEntry which can be used to cancel the operation and
/// restore the rolling count. If governor is full, returns None.
pub fn create_entry(&self, conn: &mut diesel::PgConnection) -> Result<Option<GovernorEntry>> {
let entry = diesel::insert_into(governor_entries::table)
.values((
@ -101,12 +101,10 @@ impl Governor {
}
}
/**
* Governors work by continually incrementing a counter and then
* periodically decrementing it as entries fall out of the current window of
* time. This function performs the latter part of the cycle, sweeping out
* expired entries and adjusting the counter accordingly.
*/
/// Governors work by continually incrementing a counter and then
/// periodically decrementing it as entries fall out of the current window of
/// time. This function performs the latter part of the cycle, sweeping out
/// expired entries and adjusting the counter accordingly.
pub fn reclaim(&self, conn: &mut diesel::PgConnection) -> Result<()> {
let n_expired_entries: i32 = diesel::delete(
GovernorEntry::belonging_to(self).filter(
@ -118,7 +116,7 @@ impl Governor {
.try_into()
.expect("a governor should never have been allowed enough entries to overflow an i32");
// Clamp rolling_count >= 0
diesel::update(governors::table.filter(Self::with_id(self.id.clone())))
diesel::update(governors::table.filter(Self::with_id(&self.id)))
.set(
governors::rolling_count
.eq(greatest(governors::rolling_count - n_expired_entries, 0)),
@ -127,6 +125,7 @@ impl Governor {
Ok(())
}
/// Run reclaim() on all governors with expired entries.
pub fn reclaim_all(conn: &mut diesel::PgConnection) -> Result<()> {
let applicable_governors = governors::table
.inner_join(governor_entries::table)
@ -147,10 +146,8 @@ impl Governor {
Ok(())
}
/**
* Reset all governors to a count of 0, to fix any accumulated error between
* rolling counts and number of entries.
*/
/// Reset all governors to a count of 0, to fix any accumulated error
/// between rolling counts and number of entries.
pub fn reset_all(conn: &mut diesel::PgConnection) -> Result<()> {
// Delete entries and then reset counts, not vice-versa; otherwise
// concurrent inserts could result in rolling counts getting stuck
@ -178,13 +175,11 @@ impl GovernorEntry {
governor_entries::id.eq(entry_id)
}
/**
* Removes this entry from the governor and decrements the overall rolling
* count by 1.
*/
/// Removes this entry from the governor and decrements the overall rolling
/// count by 1.
pub fn cancel(&self, conn: &mut diesel::PgConnection) -> Result<()> {
let entry_filter = Self::with_id(self.id.clone());
let governor_filter = Governor::with_id(self.governor_id.clone());
let entry_filter = Self::with_id(self.id);
let governor_filter = Governor::with_id(&self.governor_id);
diesel::update(governors::table.filter(governor_filter))
.set(governors::rolling_count.eq(greatest(governors::rolling_count - 1, 0)))
.execute(conn)?;

View file

@ -1,3 +1,4 @@
use anyhow::Result;
use deadpool_diesel::postgres::Connection;
use diesel::prelude::*;
use uuid::Uuid;
@ -7,24 +8,31 @@ use crate::{
users::User,
};
/// Returns a ForbiddenError if user is not a member of the indicated team.
/// Intended to be used in HTTP handlers to check authorization. The team
/// struct is often useful in such cases, so it is returned if the
/// authorization check is successful.
pub async fn require_team_membership(
current_user: &User,
team_id: &Uuid,
db_conn: &Connection,
) -> Result<Team, AppError> {
let current_user_id = current_user.id.clone();
let team_id = team_id.clone();
match db_conn
.interact(move |conn| {
TeamMembership::all()
.filter(TeamMembership::with_user_id(current_user_id))
.filter(TeamMembership::with_team_id(team_id))
.first(conn)
.optional()
})
.await
.unwrap()?
{
let maybe_team = {
let current_user_id = current_user.id;
let team_id = *team_id;
db_conn
.interact::<_, Result<Option<(Team, _)>>>(move |conn| {
TeamMembership::all()
.filter(TeamMembership::with_user_id(&current_user_id))
.filter(TeamMembership::with_team_id(&team_id))
.first(conn)
.optional()
.map_err(Into::into)
})
.await
.unwrap()?
};
match maybe_team {
Some((team, _)) => Ok(team),
None => Err(AppError::ForbiddenError(
"not a member of requested team".to_string(),
@ -32,12 +40,14 @@ pub async fn require_team_membership(
}
}
/// Returns a ForbiddenError if the CSRF token parameters do not match an entry
/// in the database. Do not expect this function to invalidate tokens after use.
pub async fn require_valid_csrf_token(
csrf_token: &str,
current_user: &User,
db_conn: &Connection,
) -> Result<(), AppError> {
if validate_csrf_token(db_conn, csrf_token, Some(current_user.id.clone())).await? {
if validate_csrf_token(db_conn, csrf_token, Some(current_user.id)).await? {
Ok(())
} else {
Err(AppError::ForbiddenError("invalid CSRF token".to_string()))

View file

@ -1,33 +1,10 @@
mod api_keys;
mod app_error;
mod app_state;
mod auth;
mod channel_selections;
mod channels;
mod csrf;
mod email;
mod governors;
mod guards;
mod messages;
mod nav_state;
mod projects;
mod router;
mod schema;
mod sessions;
mod settings;
mod team_memberships;
mod teams;
mod users;
mod v0_router;
mod worker;
use std::process::exit;
use axum::{extract::Request, middleware::map_request, ServiceExt};
use app_state::App;
use axum::middleware::map_request;
use chrono::{TimeDelta, Utc};
use clap::{Parser, Subcommand};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use email::SmtpOptions;
use dotenvy::dotenv;
use middleware::lowercase_uri_path;
use tokio::time::sleep;
use tower::ServiceBuilder;
use tower_http::{
@ -35,10 +12,34 @@ use tower_http::{
};
use tracing_subscriber::EnvFilter;
use crate::{
app_state::AppState, email::Mailer, router::new_router, sessions::PgStore, settings::Settings,
worker::run_worker,
};
use crate::{app_state::AppState, router::new_router, settings::Settings, worker::run_worker};
pub mod api_keys;
pub mod app_error;
pub mod app_state;
pub mod auth;
pub mod channel_selections;
pub mod channels;
mod channels_router;
pub mod csrf;
pub mod email;
pub mod governors;
pub mod guards;
pub mod messages;
pub mod middleware;
mod nav_state;
pub mod projects;
mod projects_router;
pub mod router;
pub mod schema;
pub mod sessions;
pub mod settings;
pub mod team_memberships;
pub mod teams;
mod teams_router;
pub mod users;
mod v0_router;
pub mod worker;
#[derive(Parser)]
#[command(version, about, long_about = None)]
@ -61,73 +62,43 @@ enum Commands {
// mechanisms like Governor::reset_all()
}
/// Run CLI
#[tokio::main]
async fn main() {
// Attempt to pre-load .env in case it contains a RUST_LOG variable
dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let settings = Settings::load().unwrap();
let cli = Cli::parse();
let database_url = settings.database_url.clone();
let manager =
deadpool_diesel::postgres::Manager::new(database_url, deadpool_diesel::Runtime::Tokio1);
let db_pool = deadpool_diesel::postgres::Pool::builder(manager)
.build()
.unwrap();
let session_store = PgStore::new(db_pool.clone());
let reqwest_client = reqwest::ClientBuilder::new()
.https_only(true)
.build()
.unwrap();
let oauth_client = auth::new_oauth_client(&settings).unwrap();
let mailer = if let Some(smtp_settings) = settings.email.smtp.clone() {
Mailer::new_smtp(SmtpOptions {
server: smtp_settings.server,
username: smtp_settings.username,
password: smtp_settings.password,
})
.unwrap()
} else if let Some(postmark_settings) = settings.email.postmark.clone() {
Mailer::new_postmark(postmark_settings.server_token)
.unwrap()
.with_reqwest_client(reqwest_client.clone())
} else {
tracing::error!("no email backend settings configured");
exit(1);
};
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
if settings.run_database_migrations == Some(1) {
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/");
// Run migrations on server startup
let conn = db_pool.get().await.unwrap();
let conn = state.db_pool.get().await.unwrap();
conn.interact(|conn| conn.run_pending_migrations(MIGRATIONS).map(|_| ()))
.await
.unwrap()
.unwrap();
}
let app_state = AppState {
db_pool: db_pool.clone(),
mailer,
oauth_client,
reqwest_client,
session_store,
settings: settings.clone(),
};
let cli = Cli::parse();
match &cli.command {
Commands::Serve => {
let router = new_router(app_state);
let router = new_router(state.clone()).layer(
ServiceBuilder::new()
.layer(map_request(lowercase_uri_path))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(NormalizePathLayer::trim_trailing_slash()),
);
let listener =
tokio::net::TcpListener::bind((settings.host.clone(), settings.port.clone()))
.await
.unwrap();
let listener = tokio::net::TcpListener::bind((settings.host.clone(), settings.port))
.await
.unwrap();
tracing::info!(
"App running at http://{}:{}{}",
settings.host,
@ -135,15 +106,7 @@ async fn main() {
settings.base_path
);
let app = ServiceExt::<Request>::into_make_service(
ServiceBuilder::new()
.layer(map_request(lowercase_uri_path))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(NormalizePathLayer::trim_trailing_slash())
.service(router),
);
axum::serve(listener, app).await.unwrap();
axum::serve(listener, router).await.unwrap();
}
Commands::Worker { auto_loop_seconds } => {
if let Some(loop_seconds) = auto_loop_seconds {
@ -151,7 +114,7 @@ async fn main() {
loop {
let t_next_loop = Utc::now() + loop_delta;
if let Err(err) = run_worker(app_state.clone()).await {
if let Err(err) = run_worker(state.clone()).await {
tracing::error!("{}", err)
}
@ -164,22 +127,8 @@ async fn main() {
}
}
} else {
run_worker(app_state).await.unwrap();
run_worker(state).await.unwrap();
}
}
}
}
async fn lowercase_uri_path<B>(mut request: Request<B>) -> Request<B> {
let path = request.uri().path().to_lowercase();
let path_and_query = match request.uri().query() {
Some(query) => format!("{}?{}", path, query),
None => path,
};
let builder =
axum::http::uri::Builder::from(request.uri().clone()).path_and_query(path_and_query);
*request.uri_mut() = builder
.build()
.expect("lowercasing URI path should not break it");
request
}

View file

@ -8,6 +8,7 @@ use uuid::Uuid;
use crate::{channels::Channel, schema::messages};
/// A "/say" message queued for sending
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = messages)]
#[diesel(belongs_to(Channel))]
@ -28,7 +29,7 @@ impl Message {
}
#[auto_type(no_type_alias)]
pub fn with_channel(channel_id: Uuid) -> _ {
pub fn with_channel<'a>(channel_id: &'a Uuid) -> _ {
messages::channel_id.eq(channel_id)
}

17
src/middleware.rs Normal file
View file

@ -0,0 +1,17 @@
use axum::http::Request;
/// Pass to axum::middleware::map_request() to transform the entire URI path
/// (but not search query) to lowercase.
pub async fn lowercase_uri_path<B>(mut request: Request<B>) -> Request<B> {
let path = request.uri().path().to_lowercase();
let path_and_query = match request.uri().query() {
Some(query) => format!("{}?{}", path, query),
None => path,
};
let builder =
axum::http::uri::Builder::from(request.uri().clone()).path_and_query(path_and_query);
*request.uri_mut() = builder
.build()
.expect("lowercasing URI path should not break it");
request
}

View file

@ -31,14 +31,14 @@ impl NavState {
}
pub fn push_team(mut self, team: &Team) -> Self {
self.team_id = Some(team.id.clone());
self.team_id = Some(team.id);
self.navbar_active_item = "teams".to_string();
self.breadcrumbs.push(Breadcrumb {
href: format!("{}/teams", self.base_path),
label: "Teams".to_string(),
});
self.breadcrumbs.push(Breadcrumb {
href: format!("{}/teams/{}", self.base_path, team.id.clone().simple()),
href: format!("{}/teams/{}", self.base_path, team.id.simple()),
label: team.name.clone(),
});
self
@ -58,17 +58,15 @@ impl NavState {
"{}/teams/{}/projects/{}",
self.base_path,
team_id,
project.id.clone().simple()
project.id.simple()
),
label: project.name.clone(),
});
Ok(self)
}
/**
* Add a breadcrumb with an href treated as a child of the previous
* breadcrumb's path (or of the base_path if no breadcrumbs exist).
*/
/// Add a breadcrumb with an href treated as a child of the previous
/// breadcrumb's path (or of the base_path if no breadcrumbs exist).
pub fn push_slug(mut self, breadcrumb: Breadcrumb) -> Self {
let starting_path = self
.breadcrumbs

View file

@ -12,8 +12,10 @@ use crate::{
teams::Team,
};
pub const DEFAULT_PROJECT_NAME: &'static str = "default";
pub const DEFAULT_PROJECT_NAME: &str = "default";
/// A project maps approximately to an application service, and allows messages
/// to be directed to an adjustable set of output channels.
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = projects)]
#[diesel(belongs_to(Team))]
@ -59,17 +61,17 @@ impl Project {
}
#[auto_type(no_type_alias)]
pub fn with_id(project_id: Uuid) -> _ {
pub fn with_id<'a>(project_id: &'a Uuid) -> _ {
projects::id.eq(project_id)
}
#[auto_type(no_type_alias)]
pub fn with_team(team_id: Uuid) -> _ {
pub fn with_team<'a>(team_id: &'a Uuid) -> _ {
projects::team_id.eq(team_id)
}
#[auto_type(no_type_alias)]
pub fn with_name(name: String) -> _ {
pub fn with_name<'a>(name: &'a str) -> _ {
projects::name.eq(name)
}

237
src/projects_router.rs Normal file
View file

@ -0,0 +1,237 @@
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(&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().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(&current_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, &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::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())
}

View file

@ -1,856 +1,38 @@
use std::collections::HashSet;
use anyhow::{anyhow, Context};
use askama_axum::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
extract::State,
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use axum_extra::extract::Form;
use diesel::{delete, dsl::insert_into, prelude::*, update};
use rand::{distributions::Uniform, Rng};
use regex::Regex;
use serde::Deserialize;
use tower_http::services::{ServeDir, ServeFile};
use uuid::Uuid;
use crate::{
api_keys::ApiKey,
app_error::AppError,
app_state::{AppState, DbConn},
auth,
channel_selections::ChannelSelection,
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
csrf::generate_csrf_token,
email::{MailSender as _, Mailer},
guards,
nav_state::{Breadcrumb, NavState},
projects::{Project, DEFAULT_PROJECT_NAME},
schema::{self, channel_selections, channels},
settings::Settings,
team_memberships::TeamMembership,
teams::Team,
users::CurrentUser,
app_state::AppState, auth, channels_router, projects_router, settings::Settings, teams_router,
v0_router,
};
const VERIFICATION_CODE_LEN: usize = 6;
const MAX_VERIFICATION_GUESSES: u32 = 100;
pub fn new_router(state: AppState) -> Router<()> {
let base_path = state.settings.base_path.clone();
let app = 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}/projects/{project_id}", get(project_page))
.route(
"/teams/{team_id}/projects/{project_id}/update-enabled-channels",
post(update_enabled_channels),
)
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
.route("/teams/{team_id}/channels", get(channels_page))
.route("/teams/{team_id}/channels/{channel_id}", get(channel_page))
.route(
"/teams/{team_id}/channels/{channel_id}/update-channel",
post(update_channel),
)
.route(
"/teams/{team_id}/channels/{channel_id}/update-email-recipient",
post(update_channel_email_recipient),
)
.route(
"/teams/{team_id}/channels/{channel_id}/verify-email",
post(verify_email),
)
.route("/teams/{team_id}/new-channel", post(post_new_channel))
.route("/new-team", get(new_team_page))
.route("/new-team", post(post_new_team))
.merge(channels_router::new_router())
.merge(projects_router::new_router())
.merge(teams_router::new_router())
.merge(v0_router::new_router())
.nest("/auth", auth::new_router())
.fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
)
.with_state(state);
let app = {
if base_path == "" {
app
} else {
Router::new().nest(&base_path, app).fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
)
}
};
app
}
async fn landing_page(State(state): State<AppState>) -> impl IntoResponse {
Redirect::to(&format!("{}/teams", state.settings.base_path))
}
async fn teams_page(
State(Settings { base_path, .. }): State<Settings>,
DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team_memberships_query = current_user.clone().team_memberships();
let teams: Vec<Team> = 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: "Teams".to_string(),
})
.set_navbar_active_item("teams");
#[derive(Template)]
#[template(path = "teams.html")]
struct ResponseTemplate {
base_path: String,
teams: Vec<Team>,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
nav_state,
teams,
}
.render()?,
))
}
async fn team_page(State(state): State<AppState>, Path(team_id): Path<Uuid>) -> 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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewApiKeyForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_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<Settings>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
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<Settings>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewTeamForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_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,
};
db_conn
.interact::<_, Result<(), AppError>>(move |conn| {
conn.transaction::<(), AppError, _>(move |conn| {
insert_into(schema::teams::table)
.values(&team)
.execute(conn)?;
insert_into(schema::team_memberships::table)
.values(&team_membership)
.execute(conn)?;
Project::insert_new(conn, &team.id, DEFAULT_PROJECT_NAME)?;
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<Settings>,
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_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()?;
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..].to_string()
))
}
}
#[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.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())
}
async fn channels_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(&current_user, &team_id, &db_conn).await?;
let channels = {
let team_id = team_id.clone();
db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_team(&team_id))
.load(conn)
})
.await
.unwrap()
.context("Failed to load channels list.")?
};
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: "channels".to_string(),
label: "Channels".to_string(),
})
.set_navbar_active_item("channels");
#[derive(Template)]
#[template(path = "channels.html")]
struct ResponseTemplate {
base_path: String,
channels: Vec<Channel>,
csrf_token: String,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
channels,
csrf_token,
nav_state,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
struct NewChannelPostFormBody {
csrf_token: String,
channel_type: String,
}
async fn post_new_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<NewChannelPostFormBody>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
let channel_id = Uuid::now_v7();
let channel = match form_body.channel_type.as_str() {
CHANNEL_BACKEND_EMAIL => db_conn
.interact::<_, Result<Channel, AppError>>(move |conn| {
Ok(insert_into(channels::table)
.values((
channels::id.eq(channel_id),
channels::team_id.eq(team_id),
channels::name.eq("Untitled Email Channel"),
channels::backend_config
.eq(Into::<BackendConfig>::into(EmailBackendConfig::default())),
))
.returning(Channel::as_returning())
.get_result(conn)
.context("Failed to insert new EmailChannel.")?)
})
.await
.unwrap()?,
_ => {
return Err(AppError::BadRequestError(
"Channel type not recognized.".to_string(),
));
}
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team.id.simple(),
channel.id.simple()
)))
}
async fn channel_page(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_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 channel = {
let channel_id = channel_id.clone();
let team_id = team_id.clone();
match db_conn
.interact(move |conn| {
Channel::all()
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id))
.first(conn)
.optional()
})
.await
.unwrap()?
{
None => {
return Err(AppError::NotFoundError(
"Channel with that team and ID not found".to_string(),
));
}
Some(channel) => channel,
}
};
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: "channels".to_string(),
label: "Channels".to_string(),
})
.push_slug(Breadcrumb {
href: channel.id.simple().to_string(),
label: channel.name.clone(),
})
.set_navbar_active_item("channels");
match channel.backend_config {
BackendConfig::Email(_) => {
#[derive(Template)]
#[template(path = "channel-email.html")]
struct ResponseTemplate {
base_path: String,
channel: Channel,
csrf_token: String,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
channel,
csrf_token,
nav_state,
}
.render()?,
))
}
BackendConfig::Slack(_) => {
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
}
if base_path.is_empty() {
app
} else {
Router::new().nest(&base_path, app).fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
)
}
}
#[derive(Deserialize)]
struct UpdateChannelFormBody {
csrf_token: String,
name: String,
enable_by_default: Option<String>,
}
async fn update_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelFormBody>,
) -> 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?;
let updated_rows = {
let channel_id = channel_id.clone();
let team_id = team_id.clone();
db_conn
.interact(move |conn| {
update(
channels::table
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id)),
)
.set((
channels::name.eq(form_body.name),
channels::enable_by_default
.eq(form_body.enable_by_default.unwrap_or("false".to_string()) == "true"),
))
.execute(conn)
})
.await
.unwrap()
.context("Failed to load Channel while updating.")?
};
if updated_rows != 1 {
return Err(AppError::NotFoundError(
"Channel with that team and ID not found".to_string(),
));
}
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
))
.into_response())
}
/**
* Helper function to query a channel from the database by ID and team, and
* return an appropriate error if no such channel exists.
*/
fn get_channel_by_params<'a>(
conn: &mut PgConnection,
team_id: &'a Uuid,
channel_id: &'a Uuid,
) -> Result<Channel, AppError> {
match Channel::all()
.filter(Channel::with_id(channel_id))
.filter(Channel::with_team(team_id))
.first(conn)
{
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError(
"Channel with that team and ID not found.".to_string(),
)),
diesel::QueryResult::Err(err) => Err(err.into()),
diesel::QueryResult::Ok(channel) => Ok(channel),
}
}
#[derive(Deserialize)]
struct UpdateChannelEmailRecipientFormBody {
// Yes it's a mouthful, but it's only used twice
csrf_token: String,
recipient: String,
}
async fn update_channel_email_recipient(
State(Settings {
base_path,
email: email_settings,
..
}): State<Settings>,
DbConn(db_conn): DbConn,
State(mailer): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
) -> 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?;
if !is_permissible_email(&form_body.recipient) {
return Err(AppError::BadRequestError(
"Unable to validate email address format.".to_string(),
));
}
let verification_code: String = rand::thread_rng()
.sample_iter(&Uniform::try_from(0..9).unwrap())
.take(VERIFICATION_CODE_LEN)
.map(|n| n.to_string())
.collect();
{
let verification_code = verification_code.clone();
let recipient = form_body.recipient.clone();
let channel_id = channel_id.clone();
let team_id = team_id.clone();
db_conn
.interact(move |conn| {
// TODO: transaction retries
conn.transaction::<_, AppError, _>(move |conn| {
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
let new_config = BackendConfig::Email(EmailBackendConfig {
recipient,
verification_code,
verification_code_guesses: 0,
..channel.backend_config.try_into()?
});
let num_rows = update(channels::table.filter(Channel::with_id(&channel.id)))
.set(channels::backend_config.eq(new_config))
.execute(conn)?;
if num_rows != 1 {
return Err(anyhow!(
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
num_rows
)
.into());
}
Ok(())
})
})
.await
.unwrap()?;
}
tracing::debug!(
"Email verification code for {} is: {}",
form_body.recipient,
verification_code
);
tracing::info!(
"Sending email verification code to: {}",
form_body.recipient
);
let email = crate::email::Message {
from: email_settings.verification_from.into(),
to: form_body.recipient.parse()?,
subject: "Verify Your Email".to_string(),
text_body: format!("Your email verification code is: {}", verification_code),
};
mailer.send_batch(vec![email]).await.remove(0)?;
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
)))
}
/**
* Returns true if the email address matches a format recognized as "valid".
* Not all "legal" email addresses will be accepted, but addresses that are
* "illegal" and/or could result in unexpected behavior should be rejected.
*/
fn is_permissible_email(address: &str) -> bool {
let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$")
.expect("email validation regex should parse");
re.is_match(address)
}
#[derive(Deserialize)]
struct VerifyEmailFormBody {
csrf_token: String,
code: String,
}
async fn verify_email(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<VerifyEmailFormBody>,
) -> 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?;
if form_body.code.len() != VERIFICATION_CODE_LEN {
return Err(AppError::BadRequestError(format!(
"Verification code must be {} characters long.",
VERIFICATION_CODE_LEN
)));
}
{
let channel_id = channel_id.clone();
let team_id = team_id.clone();
let verification_code = form_body.code;
db_conn
.interact(move |conn| {
conn.transaction::<(), AppError, _>(move |conn| {
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
let config: EmailBackendConfig = channel.backend_config.try_into()?;
if config.verified {
return Err(AppError::BadRequestError(
"Channel's email address is already verified.".to_string(),
));
}
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
return Err(AppError::BadRequestError(
"Verification expired.".to_string(),
));
}
let new_config = if config.verification_code == verification_code {
EmailBackendConfig {
verified: true,
verification_code: "".to_string(),
verification_code_guesses: 0,
..config
}
} else {
EmailBackendConfig {
verification_code_guesses: config.verification_code_guesses + 1,
..config
}
};
update(channels::table.filter(Channel::with_id(&channel_id)))
.set(channels::backend_config.eq(Into::<BackendConfig>::into(new_config)))
.execute(conn)?;
Ok(())
})
})
.await
.unwrap()?;
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
)))
}
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(&current_user, &team_id, &db_conn).await?;
let project_id_filter = Project::with_id(project_id.clone());
let project_team_filter = Project::with_team(team_id.clone());
let project = db_conn
.interact(move |conn| {
match Project::all()
.filter(project_id_filter)
.filter(project_team_filter)
.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 = {
let team_id = team.id.clone();
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, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let id_filter = Project::with_id(project_id.clone());
let team_filter = Project::with_team(team_id.clone());
db_conn
.interact(move |conn| -> Result<(), AppError> {
let project = match Project::all()
.filter(id_filter)
.filter(team_filter)
.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()),
}?;
delete(
channel_selections::table
.filter(ChannelSelection::with_project(project.id.clone()))
.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 {
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())
async fn landing_page(State(Settings { base_path, .. }): State<Settings>) -> impl IntoResponse {
Redirect::to(&format!("{}/teams", base_path))
}

View file

@ -37,7 +37,7 @@ impl PgStore {
impl std::fmt::Debug for PgStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PgStore")?;
Ok(()).into()
Ok(())
}
}
@ -78,7 +78,7 @@ impl SessionStore for PgStore {
async fn store_session(&self, session: Session) -> Result<Option<String>> {
let serialized_data = serde_json::to_string(&session)?;
let session_id = session.id().to_string();
let expiry = session.expiry().map(|exp| exp.clone());
let expiry = session.expiry().copied();
let conn = self.pool.get().await?;
conn.interact(move |conn| {
diesel::insert_into(browser_sessions::table)

View file

@ -13,9 +13,7 @@ pub struct Settings {
pub database_url: String,
/**
* When set to 1, embedded Diesel migrations will be run on startup.
*/
/// When set to 1, embedded Diesel migrations will be run on startup.
pub run_database_migrations: Option<u8>,
#[serde(default = "default_host")]
@ -106,7 +104,7 @@ impl Settings {
.add_source(Environment::default().separator("__"))
.build()
.context("config error")?;
Ok(s.try_deserialize().context("deserialize error")?)
s.try_deserialize().context("deserialize error")
}
}

View file

@ -1,20 +1,18 @@
use diesel::{
dsl::{AsSelect, Eq},
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
};
use uuid::Uuid;
use crate::{
schema::{self, team_memberships::dsl::*},
schema::{team_memberships, teams, users},
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))]
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = team_memberships)]
#[diesel(primary_key(team_id, user_id))]
#[diesel(check_for_backend(Pg))]
pub struct TeamMembership {
@ -26,17 +24,19 @@ 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)
team_memberships::table
.inner_join(teams::table)
.inner_join(users::table)
.select(select)
}
pub fn with_team_id(team_id_value: Uuid) -> Eq<team_id, Uuid> {
team_id.eq(team_id_value)
#[auto_type(no_type_alias)]
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
team_memberships::team_id.eq(id)
}
pub fn with_user_id(user_id_value: Uuid) -> Eq<user_id, Uuid> {
user_id.eq(user_id_value)
#[auto_type(no_type_alias)]
pub fn with_user_id<'a>(id: &'a Uuid) -> _ {
team_memberships::user_id.eq(id)
}
}

View file

@ -10,6 +10,9 @@ use crate::{
schema::{api_keys, teams},
};
/// Teams are the fundamental organizing unit for billing and help to
/// distribute ownership of projects and other resources across multiple
/// users rather than forcing a single user account to own them.
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = teams)]
#[diesel(check_for_backend(Pg))]
@ -26,10 +29,9 @@ impl Team {
}
#[auto_type(no_type_alias)]
pub fn api_keys(self) -> _ {
pub fn api_keys(&self) -> _ {
let all: diesel::dsl::Select<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
let id: Uuid = self.id;
let filter: Eq<api_keys::team_id, Uuid> = ApiKey::with_team(id);
let filter: Eq<api_keys::team_id, &Uuid> = ApiKey::with_team(&self.id);
all.filter(filter)
}
}

178
src/teams_router.rs Normal file
View file

@ -0,0 +1,178 @@
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},
csrf::generate_csrf_token,
guards,
nav_state::{Breadcrumb, NavState},
projects::{Project, DEFAULT_PROJECT_NAME},
schema::{team_memberships, teams},
settings::Settings,
team_memberships::TeamMembership,
teams::Team,
users::CurrentUser,
};
pub fn new_router() -> Router<AppState> {
Router::new()
.route("/teams", get(teams_page))
.route("/teams/{team_id}", get(team_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))
}
async fn teams_page(
State(Settings { base_path, .. }): State<Settings>,
DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let teams: Vec<Team> = {
let current_user = current_user.clone();
conn.interact(move |conn| current_user.team_memberships().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: "Teams".to_string(),
})
.set_navbar_active_item("teams");
#[derive(Template)]
#[template(path = "teams.html")]
struct ResponseTemplate {
base_path: String,
teams: Vec<Team>,
nav_state: NavState,
}
Ok(Html(
ResponseTemplate {
base_path,
nav_state,
teams,
}
.render()?,
))
}
async fn team_page(
State(Settings { base_path, .. }): State<Settings>,
Path(team_id): Path<Uuid>,
) -> impl IntoResponse {
Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id))
}
#[derive(Deserialize)]
struct PostNewApiKeyForm {
csrf_token: String,
}
async fn post_new_api_key(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewApiKeyForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
ApiKey::generate_for_team(&db_conn, team.id).await?;
Ok(Redirect::to(&format!(
"{}/teams/{}/projects",
base_path,
team.id.hyphenated()
))
.into_response())
}
async fn new_team_page(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
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<Settings>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewTeamForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team_id = Uuid::now_v7();
let team = Team {
id: team_id,
name: form.name,
};
let team_membership = TeamMembership {
team_id,
user_id: current_user.id,
};
db_conn
.interact::<_, Result<(), AppError>>(move |conn| {
conn.transaction::<(), AppError, _>(move |conn| {
diesel::insert_into(teams::table)
.values(&team)
.execute(conn)?;
diesel::insert_into(team_memberships::table)
.values(&team_membership)
.execute(conn)?;
Project::insert_new(conn, &team.id, DEFAULT_PROJECT_NAME)?;
Ok(())
})
})
.await
.unwrap()
.unwrap();
ApiKey::generate_for_team(&db_conn, team_id).await?;
Ok(Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id)).into_response())
}

View file

@ -1,10 +1,5 @@
use anyhow::Context;
use axum::{
extract::FromRequestParts,
http::request::Parts,
response::{IntoResponse, Redirect, Response},
RequestPartsExt,
};
use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt};
use diesel::{
associations::Identifiable,
deserialize::Queryable,
@ -38,15 +33,15 @@ impl User {
users::table.select(User::as_select())
}
pub fn with_uid(uid_value: &str) -> Eq<users::uid, &str> {
#[auto_type(no_type_alias)]
pub fn with_uid(uid_value: &str) -> _ {
users::uid.eq(uid_value)
}
#[auto_type(no_type_alias)]
pub fn team_memberships(self) -> _ {
let user_id: Uuid = self.id.clone();
let user_id_filter: Eq<team_memberships::user_id, Uuid> =
TeamMembership::with_user_id(user_id);
pub fn team_memberships(&self) -> _ {
let user_id_filter: Eq<team_memberships::user_id, &Uuid> =
TeamMembership::with_user_id(&self.id);
let select: AsSelect<(TeamMembership, Team), Pg> = <(TeamMembership, Team)>::as_select();
team_memberships::table
.inner_join(teams::table)
@ -59,7 +54,7 @@ impl User {
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = CurrentUserRejection;
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
@ -68,12 +63,13 @@ impl FromRequestParts<AppState> for CurrentUser {
let auth_info = parts
.extract_with_state::<AuthInfo, AppState>(state)
.await
.map_err(|_| CurrentUserRejection::AuthRequired(state.settings.base_path.clone()))?;
.map_err(|_| {
AppError::auth_redirect_from_base_path(state.settings.base_path.clone())
})?;
let current_user = state
.db_pool
.get()
.await
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?
.await?
.interact(move |conn| {
let maybe_current_user = User::all()
.filter(User::with_uid(&auth_info.sub))
@ -111,24 +107,7 @@ impl FromRequestParts<AppState> for CurrentUser {
}
})
.await
.unwrap()
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?;
.unwrap()?;
Ok(CurrentUser(current_user))
}
}
pub enum CurrentUserRejection {
AuthRequired(String),
InternalServerError(AppError),
}
impl IntoResponse for CurrentUserRejection {
fn into_response(self) -> Response {
match self {
Self::AuthRequired(base_path) => {
Redirect::to(&format!("{}/auth/login", base_path)).into_response()
}
Self::InternalServerError(err) => err.into_response(),
}
}
}

View file

@ -31,8 +31,8 @@ const TEAM_GOVERNOR_DEFAULT_MAX_COUNT: i32 = 50;
static RE_PROJECT_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9_-]{1,100}$").unwrap());
pub fn new_router(state: AppState) -> Router<AppState> {
Router::new().route("/say", get(say_get)).with_state(state)
pub fn new_router() -> Router<AppState> {
Router::new().route("/say", get(say_get))
}
#[derive(Deserialize, Validate)]
@ -72,7 +72,7 @@ async fn say_get(
)))?;
db_conn
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
update(api_keys::table.filter(ApiKey::with_id(query_key)))
update(api_keys::table.filter(ApiKey::with_id(&query_key)))
.set(api_keys::last_used_at.eq(diesel::dsl::now))
.returning(ApiKey::as_returning())
.get_result(conn)
@ -91,8 +91,8 @@ async fn say_get(
conn.transaction(move |conn| {
Ok(
match Project::all()
.filter(Project::with_team(api_key.team_id))
.filter(Project::with_name(project_name.clone()))
.filter(Project::with_team(&api_key.team_id))
.filter(Project::with_name(&project_name))
.first(conn)
.optional()
.context("failed to load project")?
@ -109,14 +109,14 @@ async fn say_get(
};
let team_governor = {
let team_id = project.team_id.clone();
let team_id = project.team_id;
db_conn
.interact::<_, Result<Governor, AppError>>(move |conn| {
// TODO: extract this logic to a method in crate::governors,
// and create governor proactively on team creation
match Governor::all()
.filter(Governor::with_team(team_id.clone()))
.filter(Governor::with_project(None))
.filter(Governor::with_team(&team_id))
.filter(Governor::with_project(&None))
.first(conn)
{
diesel::QueryResult::Ok(governor) => Ok(governor),

View file

@ -15,7 +15,7 @@ use crate::{
pub async fn run_worker(state: AppState) -> Result<()> {
async move {
process_messages(state.clone()).await?;
reclaim_governor_entries(state.clone()).await?;
reclaim_governor_entries(state).await?;
Ok(())
}
.instrument(tracing::debug_span!("run_worker()"))
@ -62,13 +62,13 @@ async fn process_messages(state: AppState) -> Result<()> {
return None;
};
let email = crate::email::Message {
from: state.settings.email.message_from.clone().into(),
from: state.settings.email.message_from.clone(),
to: recipient.into(),
subject: "Shout".to_string(),
text_body: message.message.clone(),
};
tracing::debug!("Sending email to recipient for channel {}", channel.id);
Some((message.id.clone(), email))
Some((message.id, email))
} else {
tracing::info!(
"Email recipient for channel {} is not verified",
@ -82,7 +82,7 @@ async fn process_messages(state: AppState) -> Result<()> {
})
.collect();
if !emails.is_empty() {
let message_ids: Vec<Uuid> = emails.iter().map(|(id, _)| id.clone()).collect();
let message_ids: Vec<Uuid> = emails.iter().map(|(id, _)| *id).collect();
let results = state
.mailer
.send_batch(emails.into_iter().map(|(_, email)| email).collect())
@ -115,10 +115,7 @@ async fn process_messages(state: AppState) -> Result<()> {
async fn reclaim_governor_entries(state: AppState) -> Result<()> {
async move {
let db_conn = state.db_pool.get().await?;
db_conn
.interact(move |conn| Governor::reclaim_all(conn))
.await
.unwrap()?;
db_conn.interact(Governor::reclaim_all).await.unwrap()?;
Ok(())
}
// This doesn't do much, since it seems that tracing spans don't carry