forked from 2sys/shoutdotdev
overarching refactor and cleanup
This commit is contained in:
parent
c9912ff332
commit
cd63f87f1b
26 changed files with 1207 additions and 1169 deletions
|
@ -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
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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 {
|
||||
|
|
47
src/auth.rs
47
src/auth.rs
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
455
src/channels_router.rs
Normal 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(¤t_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(¤t_user, &team_id, &db_conn).await?;
|
||||
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_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(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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()
|
||||
)))
|
||||
}
|
18
src/csrf.rs
18
src/csrf.rs
|
@ -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()
|
||||
|
|
58
src/email.rs
58
src/email.rs
|
@ -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!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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(¤t_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()))
|
||||
|
|
153
src/main.rs
153
src/main.rs
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
17
src/middleware.rs
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
237
src/projects_router.rs
Normal 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(¤t_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(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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())
|
||||
}
|
850
src/router.rs
850
src/router.rs
|
@ -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, ¤t_user, &db_conn).await?;
|
||||
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||
|
||||
ApiKey::generate_for_team(&db_conn, team.id.clone()).await?;
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/teams/{}/projects",
|
||||
base_path,
|
||||
team.id.hyphenated().to_string()
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn new_team_page(
|
||||
State(Settings { base_path, .. }): State<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, ¤t_user, &db_conn).await?;
|
||||
|
||||
let team_id = Uuid::now_v7();
|
||||
let team = Team {
|
||||
id: team_id.clone(),
|
||||
name: form.name,
|
||||
};
|
||||
let team_membership = TeamMembership {
|
||||
team_id: team_id.clone(),
|
||||
user_id: current_user.id,
|
||||
};
|
||||
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(¤t_user, &team_id, &db_conn).await?;
|
||||
|
||||
let api_keys_query = team.clone().api_keys();
|
||||
let (api_keys, projects) = db_conn
|
||||
.interact(move |conn| {
|
||||
diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?))
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
||||
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(¤t_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(¤t_user, &team_id, &db_conn).await?;
|
||||
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_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(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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(¤t_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, ¤t_user, &db_conn).await?;
|
||||
guards::require_team_membership(¤t_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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
178
src/teams_router.rs
Normal 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, ¤t_user, &db_conn).await?;
|
||||
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||
|
||||
ApiKey::generate_for_team(&db_conn, team.id).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, ¤t_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())
|
||||
}
|
45
src/users.rs
45
src/users.rs
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue