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};
|
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)]
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = api_keys)]
|
#[diesel(table_name = api_keys)]
|
||||||
#[diesel(belongs_to(Team))]
|
#[diesel(belongs_to(Team))]
|
||||||
|
@ -46,27 +48,23 @@ impl ApiKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn with_id(id: Uuid) -> _ {
|
pub fn with_id<'a>(id: &'a Uuid) -> _ {
|
||||||
api_keys::id.eq(id)
|
api_keys::id.eq(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
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 {
|
pub fn compact_uuid(id: &Uuid) -> String {
|
||||||
URL_SAFE_NO_PAD.encode(id.as_bytes())
|
URL_SAFE_NO_PAD.encode(id.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Attempt to parse a string as either a standard formatted UUID or a
|
||||||
* Attempt to parse a string as either a standard formatted UUID or a big-endian
|
/// big-endian base64 encoding of one.
|
||||||
* base64 encoding of one.
|
|
||||||
*/
|
|
||||||
pub fn try_parse_as_uuid(value: &str) -> Result<Uuid> {
|
pub fn try_parse_as_uuid(value: &str) -> Result<Uuid> {
|
||||||
if value.len() < 32 {
|
if value.len() < 32 {
|
||||||
let bytes: Vec<u8> = URL_SAFE_NO_PAD
|
let bytes: Vec<u8> = URL_SAFE_NO_PAD
|
||||||
|
|
|
@ -9,8 +9,7 @@ pub struct AuthRedirectInfo {
|
||||||
base_path: String,
|
base_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use anyhow, define error and enable '?'
|
/// Custom error type that maps to appropriate HTTP responses.
|
||||||
// For a simplified example of using anyhow in axum check /examples/anyhow-error-response
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
InternalServerError(anyhow::Error),
|
InternalServerError(anyhow::Error),
|
||||||
|
@ -27,13 +26,13 @@ impl AppError {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
|
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
|
||||||
|
// TODO: customize validation errors formatting
|
||||||
Self::BadRequestError(
|
Self::BadRequestError(
|
||||||
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
|
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell axum how to convert `AppError` into a response.
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match self {
|
match self {
|
||||||
|
@ -67,8 +66,7 @@ impl IntoResponse for AppError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
|
// Easily convert semi-arbitrary errors to InternalServerError
|
||||||
// `Result<_, AppError>`. That way you don't need to do that manually.
|
|
||||||
impl<E> From<E> for AppError
|
impl<E> From<E> for AppError
|
||||||
where
|
where
|
||||||
E: Into<anyhow::Error>,
|
E: Into<anyhow::Error>,
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRef, FromRequestParts},
|
extract::{FromRef, FromRequestParts},
|
||||||
http::request::Parts,
|
http::request::Parts,
|
||||||
|
@ -5,10 +8,15 @@ use axum::{
|
||||||
use deadpool_diesel::postgres::{Connection, Pool};
|
use deadpool_diesel::postgres::{Connection, Pool};
|
||||||
use oauth2::basic::BasicClient;
|
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)]
|
/// Global app configuration
|
||||||
pub struct AppState {
|
pub struct App {
|
||||||
pub db_pool: Pool,
|
pub db_pool: Pool,
|
||||||
pub mailer: Mailer,
|
pub mailer: Mailer,
|
||||||
pub reqwest_client: reqwest::Client,
|
pub reqwest_client: reqwest::Client,
|
||||||
|
@ -17,6 +25,46 @@ pub struct AppState {
|
||||||
pub settings: Settings,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct ReqwestClient(pub reqwest::Client);
|
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);
|
pub struct DbConn(pub Connection);
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for DbConn {
|
impl FromRequestParts<AppState> for DbConn {
|
||||||
|
|
47
src/auth.rs
47
src/auth.rs
|
@ -22,11 +22,12 @@ use crate::{
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SESSION_KEY_AUTH_CSRF_TOKEN: &'static str = "oauth_csrf_token";
|
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_token";
|
||||||
const SESSION_KEY_AUTH_REFRESH_TOKEN: &'static str = "oauth_refresh_token";
|
const SESSION_KEY_AUTH_REFRESH_TOKEN: &str = "oauth_refresh_token";
|
||||||
const SESSION_KEY_AUTH_INFO: &'static str = "auth";
|
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(
|
Ok(BasicClient::new(
|
||||||
ClientId::new(settings.auth.client_id.clone()),
|
ClientId::new(settings.auth.client_id.clone()),
|
||||||
Some(ClientSecret::new(settings.auth.client_secret.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> {
|
pub fn new_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", get(start_login))
|
.route("/login", get(start_login))
|
||||||
.route("/callback", get(login_authorized))
|
.route("/callback", get(callback))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_login(
|
/// HTTP get handler for /login
|
||||||
|
async fn start_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
State(Settings {
|
State(Settings {
|
||||||
auth: auth_settings,
|
auth: auth_settings,
|
||||||
|
@ -84,10 +87,11 @@ pub async fn start_login(
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.path("/"),
|
.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 {
|
State(Settings {
|
||||||
base_path,
|
base_path,
|
||||||
auth: auth_settings,
|
auth: auth_settings,
|
||||||
|
@ -128,18 +132,14 @@ pub async fn logout(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AuthRequestQuery {
|
struct AuthRequestQuery {
|
||||||
code: String,
|
code: String,
|
||||||
state: String, // CSRF token
|
/// CSRF token
|
||||||
|
state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
/// HTTP get handler for /callback
|
||||||
pub struct AuthInfo {
|
async fn callback(
|
||||||
pub sub: String,
|
|
||||||
pub email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login_authorized(
|
|
||||||
Query(query): Query<AuthRequestQuery>,
|
Query(query): Query<AuthRequestQuery>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
State(Settings {
|
State(Settings {
|
||||||
|
@ -153,9 +153,7 @@ pub async fn login_authorized(
|
||||||
let mut session = if let Some(session) = session {
|
let mut session = if let Some(session) = session {
|
||||||
session
|
session
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::auth_redirect_from_base_path(
|
return Err(AppError::auth_redirect_from_base_path(base_path));
|
||||||
state.settings.base_path,
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
|
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
|
||||||
tracing::debug!("oauth csrf token not found on session");
|
tracing::debug!("oauth csrf token not found on session");
|
||||||
|
@ -194,6 +192,13 @@ pub async fn login_authorized(
|
||||||
Ok(Redirect::to(&format!("{}/", base_path)))
|
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 {
|
impl FromRequestParts<AppState> for AuthInfo {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
@ -214,7 +219,7 @@ impl FromRequestParts<AppState> for AuthInfo {
|
||||||
)?;
|
)?;
|
||||||
Ok(user)
|
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()"))
|
.instrument(trace_span!("AuthInfo from_request_parts()"))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,12 @@ impl ChannelSelection {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
channel_selections::channel_id.eq(channel_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
channel_selections::project_id.eq(project_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,15 +14,13 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{schema::channels, teams::Team};
|
use crate::{schema::channels, teams::Team};
|
||||||
|
|
||||||
pub const CHANNEL_BACKEND_EMAIL: &'static str = "email";
|
pub const CHANNEL_BACKEND_EMAIL: &str = "email";
|
||||||
pub const CHANNEL_BACKEND_SLACK: &'static str = "slack";
|
pub const CHANNEL_BACKEND_SLACK: &str = "slack";
|
||||||
|
|
||||||
/**
|
/// Represents a target/destination for messages, with the sender configuration
|
||||||
* Represents a target/destination for messages, with the sender configuration
|
/// defined in the backend_config field. A single channel may be attached to
|
||||||
* 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
|
||||||
* (in other words, "enabled" or "selected" for) any number of projects within
|
/// the same team.
|
||||||
* the same team.
|
|
||||||
*/
|
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(belongs_to(Team))]
|
#[diesel(belongs_to(Team))]
|
||||||
#[diesel(check_for_backend(Pg))]
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
@ -57,20 +55,18 @@ impl Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Note: In a previous implementation, channel configuration was handled by
|
||||||
* Encapsulates any information that needs to be persisted for setting up or
|
// creating a dedicated table for each channel type and joining them to the
|
||||||
* using a channel's backend (that is, email sender, Slack app, etc.). This
|
// `channels` table in order to access configuration fields. The jsonb approach
|
||||||
* configuration is encoded to a jsonb column in the database, which determines
|
// simplifies database management and lends itself to a cleaner Rust
|
||||||
* the channel type along with configuration details.
|
// implementation in which this enum can be treated as a column type with
|
||||||
*
|
// enforcement of data structure invariants handled entirely in the to_sql()
|
||||||
* Note: In a previous implementation, channel configuration was handled by
|
// and from_sql() serialization/deserialization logic.
|
||||||
* creating a dedicated table for each channel type and joining them to the
|
|
||||||
* `channels` table in order to access configuration fields. The jsonb approach
|
/// Encapsulates any information that needs to be persisted for setting up or
|
||||||
* simplifies database management and lends itself to a cleaner Rust
|
/// using a channel's backend (that is, email sender, Slack app, etc.). This
|
||||||
* implementation in which this enum can be treated as a column type with
|
/// configuration is encoded to a jsonb column in the database, which determines
|
||||||
* enforcement of data structure invariants handled entirely in the to_sql()
|
/// the channel type along with configuration details.
|
||||||
* and from_sql() serialization/deserialization logic.
|
|
||||||
*/
|
|
||||||
#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)]
|
#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)]
|
||||||
#[diesel(sql_type = Jsonb)]
|
#[diesel(sql_type = Jsonb)]
|
||||||
pub enum BackendConfig {
|
pub enum BackendConfig {
|
||||||
|
@ -79,7 +75,7 @@ pub enum BackendConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToSql<Jsonb, Pg> for 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() {
|
match self.clone() {
|
||||||
BackendConfig::Email(config) => ToSql::<Jsonb, Pg>::to_sql(
|
BackendConfig::Email(config) => ToSql::<Jsonb, Pg>::to_sql(
|
||||||
&json!({
|
&json!({
|
||||||
|
@ -142,9 +138,9 @@ impl TryFrom<BackendConfig> for EmailBackendConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<BackendConfig> for EmailBackendConfig {
|
impl From<EmailBackendConfig> for BackendConfig {
|
||||||
fn into(self) -> BackendConfig {
|
fn from(value: EmailBackendConfig) -> Self {
|
||||||
BackendConfig::Email(self)
|
Self::Email(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,8 +166,8 @@ impl TryFrom<BackendConfig> for SlackBackendConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<BackendConfig> for SlackBackendConfig {
|
impl From<SlackBackendConfig> for BackendConfig {
|
||||||
fn into(self) -> BackendConfig {
|
fn from(value: SlackBackendConfig) -> Self {
|
||||||
BackendConfig::Slack(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 chrono::{DateTime, TimeDelta, Utc};
|
||||||
use deadpool_diesel::postgres::Connection;
|
use deadpool_diesel::postgres::Connection;
|
||||||
use diesel::{
|
use diesel::{
|
||||||
dsl::{AsSelect, Eq, Gt, IsNotDistinctFrom, Select},
|
dsl::{auto_type, AsSelect, Gt, Select},
|
||||||
pg::Pg,
|
pg::Pg,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{app_error::AppError, schema::csrf_tokens::dsl::*};
|
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)]
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = crate::schema::csrf_tokens)]
|
#[diesel(table_name = crate::schema::csrf_tokens)]
|
||||||
|
@ -31,15 +31,18 @@ impl CsrfToken {
|
||||||
created_at.gt(min_created_at)
|
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)
|
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)
|
id.eq(token_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience function for creating new CSRF token rows in the database.
|
||||||
pub async fn generate_csrf_token(
|
pub async fn generate_csrf_token(
|
||||||
db_conn: &Connection,
|
db_conn: &Connection,
|
||||||
with_user_id: Option<Uuid>,
|
with_user_id: Option<Uuid>,
|
||||||
|
@ -57,9 +60,10 @@ pub async fn generate_csrf_token(
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()?;
|
.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(
|
pub async fn validate_csrf_token(
|
||||||
db_conn: &Connection,
|
db_conn: &Connection,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
@ -72,8 +76,8 @@ pub async fn validate_csrf_token(
|
||||||
Ok(db_conn
|
Ok(db_conn
|
||||||
.interact(move |conn| {
|
.interact(move |conn| {
|
||||||
CsrfToken::all()
|
CsrfToken::all()
|
||||||
.filter(CsrfToken::with_token_id(token_id))
|
.filter(CsrfToken::with_token_id(&token_id))
|
||||||
.filter(CsrfToken::with_user_id(with_user_id))
|
.filter(CsrfToken::with_user_id(&with_user_id))
|
||||||
.filter(CsrfToken::is_not_expired())
|
.filter(CsrfToken::is_not_expired())
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()
|
.optional()
|
||||||
|
|
52
src/email.rs
52
src/email.rs
|
@ -1,11 +1,12 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
|
use futures::Future;
|
||||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||||
use serde::{Serialize, Serializer};
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
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)]
|
#[derive(Clone, Serialize)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
|
@ -21,11 +22,9 @@ pub struct Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MailSender: Clone + Sync {
|
pub trait MailSender: Clone + Sync {
|
||||||
/**
|
/// Attempt to send all messages defined by the input Vec. Send as many as
|
||||||
* Attempt to send all messages defined by the input Vec. Send as many as
|
/// possible, returning exactly one Result<()> for each message.
|
||||||
* possible, returning exactly one Result<()> for each message.
|
fn send_batch(&self, emails: Vec<Message>) -> impl Future<Output = Vec<Result<()>>>;
|
||||||
*/
|
|
||||||
async fn send_batch(&self, emails: Vec<Message>) -> Vec<Result<()>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
@ -61,7 +60,7 @@ impl MailSender for Mailer {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct SmtpSender {
|
pub struct SmtpSender {
|
||||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +103,7 @@ fn serialize_mailboxes<S>(t: &lettre::message::Mailboxes, s: S) -> Result<S::Ok,
|
||||||
where
|
where
|
||||||
S: Serializer,
|
S: Serializer,
|
||||||
{
|
{
|
||||||
Ok(s.serialize_str(&t.to_string())?)
|
s.serialize_str(&t.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailSender for SmtpSender {
|
impl MailSender for SmtpSender {
|
||||||
|
@ -131,7 +130,7 @@ impl MailSender for SmtpSender {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct PostmarkSender {
|
pub struct PostmarkSender {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
server_token: String,
|
server_token: String,
|
||||||
}
|
}
|
||||||
|
@ -150,14 +149,10 @@ impl PostmarkSender {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailSender for PostmarkSender {
|
impl MailSender for PostmarkSender {
|
||||||
/**
|
/// Recursively attempts to send messages, breaking them into smaller and
|
||||||
* Recursively attempts to send messages, breaking them into smaller and
|
/// smaller batches as needed.
|
||||||
* smaller batches as needed.
|
|
||||||
*/
|
|
||||||
async fn send_batch(&self, mut emails: Vec<Message>) -> Vec<Result<()>> {
|
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 {
|
macro_rules! all_ok {
|
||||||
() => {{
|
() => {{
|
||||||
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
|
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
|
||||||
* Constructs a Vec with a single specific error, followed by n-1
|
/// generic errors referring back to it.
|
||||||
* generic errors referring back to it.
|
|
||||||
*/
|
|
||||||
macro_rules! cascade_err {
|
macro_rules! cascade_err {
|
||||||
($err:expr) => {{
|
($err:expr) => {{
|
||||||
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
|
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
|
||||||
* Recursively splits the email batch in half and tries to send each
|
/// half independently, allowing both to run to completion and then
|
||||||
* half independently, allowing both to run to completion and then
|
/// returning the first error of the two results, if present.
|
||||||
* returning the first error of the two results, if present.
|
|
||||||
*
|
|
||||||
* This is implemented as a macro in order to avoid unstable async
|
|
||||||
* closures.
|
|
||||||
*/
|
|
||||||
macro_rules! split_and_retry {
|
macro_rules! split_and_retry {
|
||||||
|
// This is implemented as a macro in order to avoid unstable async
|
||||||
|
// closures.
|
||||||
() => {
|
() => {
|
||||||
if emails.len() < 2 {
|
if emails.len() < 2 {
|
||||||
tracing::warn!("Postmark send batch cannot be split any further");
|
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;
|
const POSTMARK_MAX_REQUEST_BYTES: usize = 50 * 1000 * 1000;
|
||||||
// TODO: Check email subject and body size against Postmark limits
|
// 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");
|
tracing::debug!("no Postmark messages to send");
|
||||||
vec![Ok(())]
|
vec![Ok(())]
|
||||||
} else if emails.len() > POSTMARK_MAX_BATCH_ENTRIES {
|
} else if emails.len() > POSTMARK_MAX_BATCH_ENTRIES {
|
||||||
|
@ -248,8 +238,7 @@ impl MailSender for PostmarkSender {
|
||||||
};
|
};
|
||||||
if resp.status().is_client_error() && emails.len() > 1 {
|
if resp.status().is_client_error() && emails.len() > 1 {
|
||||||
split_and_retry!()
|
split_and_retry!()
|
||||||
} else {
|
} else if let Err(err) = resp.error_for_status() {
|
||||||
if let Err(err) = resp.error_for_status() {
|
|
||||||
cascade_err!(err.into())
|
cascade_err!(err.into())
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("sent Postmark batch of {} messages", emails.len());
|
tracing::debug!("sent Postmark batch of {} messages", emails.len());
|
||||||
|
@ -259,7 +248,6 @@ impl MailSender for PostmarkSender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRef<AppState> for Mailer {
|
impl FromRef<AppState> for Mailer {
|
||||||
fn from_ref(state: &AppState) -> Mailer {
|
fn from_ref(state: &AppState) -> Mailer {
|
||||||
|
|
|
@ -12,6 +12,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::schema::{governor_entries, governors};
|
use crate::schema::{governor_entries, governors};
|
||||||
|
|
||||||
|
// Expose built-in Postgres GREATEST() function to Diesel
|
||||||
define_sql_function! {
|
define_sql_function! {
|
||||||
fn greatest(a: diesel::sql_types::Integer, b: diesel::sql_types::Integer) -> Integer
|
fn greatest(a: diesel::sql_types::Integer, b: diesel::sql_types::Integer) -> Integer
|
||||||
}
|
}
|
||||||
|
@ -54,27 +55,26 @@ impl Governor {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
governors::id.eq(governor_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
governors::team_id.eq(team_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
governors::project_id.is_not_distinct_from(project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: return a custom result enum instead of a Result<Option>, for
|
// TODO: return a custom result enum instead of a Result<Option>, for
|
||||||
// better readability
|
// better readability
|
||||||
/**
|
|
||||||
* Attempt to increment the rolling count. If the governor is not full,
|
/// Attempt to increment the rolling count. If the governor is not full,
|
||||||
* returns a GovernorEntry which can be used to cancel the operation and
|
/// returns a GovernorEntry which can be used to cancel the operation and
|
||||||
* restore the rolling count. If governor is full, returns None.
|
/// restore the rolling count. If governor is full, returns None.
|
||||||
*/
|
|
||||||
pub fn create_entry(&self, conn: &mut diesel::PgConnection) -> Result<Option<GovernorEntry>> {
|
pub fn create_entry(&self, conn: &mut diesel::PgConnection) -> Result<Option<GovernorEntry>> {
|
||||||
let entry = diesel::insert_into(governor_entries::table)
|
let entry = diesel::insert_into(governor_entries::table)
|
||||||
.values((
|
.values((
|
||||||
|
@ -101,12 +101,10 @@ impl Governor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Governors work by continually incrementing a counter and then
|
||||||
* Governors work by continually incrementing a counter and then
|
/// periodically decrementing it as entries fall out of the current window of
|
||||||
* periodically decrementing it as entries fall out of the current window of
|
/// time. This function performs the latter part of the cycle, sweeping out
|
||||||
* time. This function performs the latter part of the cycle, sweeping out
|
/// expired entries and adjusting the counter accordingly.
|
||||||
* expired entries and adjusting the counter accordingly.
|
|
||||||
*/
|
|
||||||
pub fn reclaim(&self, conn: &mut diesel::PgConnection) -> Result<()> {
|
pub fn reclaim(&self, conn: &mut diesel::PgConnection) -> Result<()> {
|
||||||
let n_expired_entries: i32 = diesel::delete(
|
let n_expired_entries: i32 = diesel::delete(
|
||||||
GovernorEntry::belonging_to(self).filter(
|
GovernorEntry::belonging_to(self).filter(
|
||||||
|
@ -118,7 +116,7 @@ impl Governor {
|
||||||
.try_into()
|
.try_into()
|
||||||
.expect("a governor should never have been allowed enough entries to overflow an i32");
|
.expect("a governor should never have been allowed enough entries to overflow an i32");
|
||||||
// Clamp rolling_count >= 0
|
// 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(
|
.set(
|
||||||
governors::rolling_count
|
governors::rolling_count
|
||||||
.eq(greatest(governors::rolling_count - n_expired_entries, 0)),
|
.eq(greatest(governors::rolling_count - n_expired_entries, 0)),
|
||||||
|
@ -127,6 +125,7 @@ impl Governor {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run reclaim() on all governors with expired entries.
|
||||||
pub fn reclaim_all(conn: &mut diesel::PgConnection) -> Result<()> {
|
pub fn reclaim_all(conn: &mut diesel::PgConnection) -> Result<()> {
|
||||||
let applicable_governors = governors::table
|
let applicable_governors = governors::table
|
||||||
.inner_join(governor_entries::table)
|
.inner_join(governor_entries::table)
|
||||||
|
@ -147,10 +146,8 @@ impl Governor {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Reset all governors to a count of 0, to fix any accumulated error
|
||||||
* Reset all governors to a count of 0, to fix any accumulated error between
|
/// between rolling counts and number of entries.
|
||||||
* rolling counts and number of entries.
|
|
||||||
*/
|
|
||||||
pub fn reset_all(conn: &mut diesel::PgConnection) -> Result<()> {
|
pub fn reset_all(conn: &mut diesel::PgConnection) -> Result<()> {
|
||||||
// Delete entries and then reset counts, not vice-versa; otherwise
|
// Delete entries and then reset counts, not vice-versa; otherwise
|
||||||
// concurrent inserts could result in rolling counts getting stuck
|
// concurrent inserts could result in rolling counts getting stuck
|
||||||
|
@ -178,13 +175,11 @@ impl GovernorEntry {
|
||||||
governor_entries::id.eq(entry_id)
|
governor_entries::id.eq(entry_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Removes this entry from the governor and decrements the overall rolling
|
||||||
* Removes this entry from the governor and decrements the overall rolling
|
/// count by 1.
|
||||||
* count by 1.
|
|
||||||
*/
|
|
||||||
pub fn cancel(&self, conn: &mut diesel::PgConnection) -> Result<()> {
|
pub fn cancel(&self, conn: &mut diesel::PgConnection) -> Result<()> {
|
||||||
let entry_filter = Self::with_id(self.id.clone());
|
let entry_filter = Self::with_id(self.id);
|
||||||
let governor_filter = Governor::with_id(self.governor_id.clone());
|
let governor_filter = Governor::with_id(&self.governor_id);
|
||||||
diesel::update(governors::table.filter(governor_filter))
|
diesel::update(governors::table.filter(governor_filter))
|
||||||
.set(governors::rolling_count.eq(greatest(governors::rolling_count - 1, 0)))
|
.set(governors::rolling_count.eq(greatest(governors::rolling_count - 1, 0)))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use anyhow::Result;
|
||||||
use deadpool_diesel::postgres::Connection;
|
use deadpool_diesel::postgres::Connection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
@ -7,24 +8,31 @@ use crate::{
|
||||||
users::User,
|
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(
|
pub async fn require_team_membership(
|
||||||
current_user: &User,
|
current_user: &User,
|
||||||
team_id: &Uuid,
|
team_id: &Uuid,
|
||||||
db_conn: &Connection,
|
db_conn: &Connection,
|
||||||
) -> Result<Team, AppError> {
|
) -> Result<Team, AppError> {
|
||||||
let current_user_id = current_user.id.clone();
|
let maybe_team = {
|
||||||
let team_id = team_id.clone();
|
let current_user_id = current_user.id;
|
||||||
match db_conn
|
let team_id = *team_id;
|
||||||
.interact(move |conn| {
|
db_conn
|
||||||
|
.interact::<_, Result<Option<(Team, _)>>>(move |conn| {
|
||||||
TeamMembership::all()
|
TeamMembership::all()
|
||||||
.filter(TeamMembership::with_user_id(current_user_id))
|
.filter(TeamMembership::with_user_id(¤t_user_id))
|
||||||
.filter(TeamMembership::with_team_id(team_id))
|
.filter(TeamMembership::with_team_id(&team_id))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()?
|
.unwrap()?
|
||||||
{
|
};
|
||||||
|
match maybe_team {
|
||||||
Some((team, _)) => Ok(team),
|
Some((team, _)) => Ok(team),
|
||||||
None => Err(AppError::ForbiddenError(
|
None => Err(AppError::ForbiddenError(
|
||||||
"not a member of requested team".to_string(),
|
"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(
|
pub async fn require_valid_csrf_token(
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
current_user: &User,
|
current_user: &User,
|
||||||
db_conn: &Connection,
|
db_conn: &Connection,
|
||||||
) -> Result<(), AppError> {
|
) -> 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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ForbiddenError("invalid CSRF token".to_string()))
|
Err(AppError::ForbiddenError("invalid CSRF token".to_string()))
|
||||||
|
|
149
src/main.rs
149
src/main.rs
|
@ -1,33 +1,10 @@
|
||||||
mod api_keys;
|
use app_state::App;
|
||||||
mod app_error;
|
use axum::middleware::map_request;
|
||||||
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 chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use email::SmtpOptions;
|
use dotenvy::dotenv;
|
||||||
|
use middleware::lowercase_uri_path;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
@ -35,10 +12,34 @@ use tower_http::{
|
||||||
};
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::{
|
use crate::{app_state::AppState, router::new_router, settings::Settings, worker::run_worker};
|
||||||
app_state::AppState, email::Mailer, router::new_router, sessions::PgStore, 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)]
|
#[derive(Parser)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
|
@ -61,71 +62,41 @@ enum Commands {
|
||||||
// mechanisms like Governor::reset_all()
|
// mechanisms like Governor::reset_all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run CLI
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// Attempt to pre-load .env in case it contains a RUST_LOG variable
|
||||||
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::from_default_env())
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let settings = Settings::load().unwrap();
|
let settings = Settings::load().unwrap();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
if settings.run_database_migrations == Some(1) {
|
if settings.run_database_migrations == Some(1) {
|
||||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/");
|
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/");
|
||||||
// Run migrations on server startup
|
// 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(|_| ()))
|
conn.interact(|conn| conn.run_pending_migrations(MIGRATIONS).map(|_| ()))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_state = AppState {
|
let cli = Cli::parse();
|
||||||
db_pool: db_pool.clone(),
|
|
||||||
mailer,
|
|
||||||
oauth_client,
|
|
||||||
reqwest_client,
|
|
||||||
session_store,
|
|
||||||
settings: settings.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Commands::Serve => {
|
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 =
|
let listener = tokio::net::TcpListener::bind((settings.host.clone(), settings.port))
|
||||||
tokio::net::TcpListener::bind((settings.host.clone(), settings.port.clone()))
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
@ -135,15 +106,7 @@ async fn main() {
|
||||||
settings.base_path
|
settings.base_path
|
||||||
);
|
);
|
||||||
|
|
||||||
let app = ServiceExt::<Request>::into_make_service(
|
axum::serve(listener, router).await.unwrap();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
Commands::Worker { auto_loop_seconds } => {
|
Commands::Worker { auto_loop_seconds } => {
|
||||||
if let Some(loop_seconds) = auto_loop_seconds {
|
if let Some(loop_seconds) = auto_loop_seconds {
|
||||||
|
@ -151,7 +114,7 @@ async fn main() {
|
||||||
loop {
|
loop {
|
||||||
let t_next_loop = Utc::now() + loop_delta;
|
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)
|
tracing::error!("{}", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,22 +127,8 @@ async fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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};
|
use crate::{channels::Channel, schema::messages};
|
||||||
|
|
||||||
|
/// A "/say" message queued for sending
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = messages)]
|
#[diesel(table_name = messages)]
|
||||||
#[diesel(belongs_to(Channel))]
|
#[diesel(belongs_to(Channel))]
|
||||||
|
@ -28,7 +29,7 @@ impl Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
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 {
|
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.navbar_active_item = "teams".to_string();
|
||||||
self.breadcrumbs.push(Breadcrumb {
|
self.breadcrumbs.push(Breadcrumb {
|
||||||
href: format!("{}/teams", self.base_path),
|
href: format!("{}/teams", self.base_path),
|
||||||
label: "Teams".to_string(),
|
label: "Teams".to_string(),
|
||||||
});
|
});
|
||||||
self.breadcrumbs.push(Breadcrumb {
|
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(),
|
label: team.name.clone(),
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
|
@ -58,17 +58,15 @@ impl NavState {
|
||||||
"{}/teams/{}/projects/{}",
|
"{}/teams/{}/projects/{}",
|
||||||
self.base_path,
|
self.base_path,
|
||||||
team_id,
|
team_id,
|
||||||
project.id.clone().simple()
|
project.id.simple()
|
||||||
),
|
),
|
||||||
label: project.name.clone(),
|
label: project.name.clone(),
|
||||||
});
|
});
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Add a breadcrumb with an href treated as a child of the previous
|
||||||
* 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).
|
||||||
* breadcrumb's path (or of the base_path if no breadcrumbs exist).
|
|
||||||
*/
|
|
||||||
pub fn push_slug(mut self, breadcrumb: Breadcrumb) -> Self {
|
pub fn push_slug(mut self, breadcrumb: Breadcrumb) -> Self {
|
||||||
let starting_path = self
|
let starting_path = self
|
||||||
.breadcrumbs
|
.breadcrumbs
|
||||||
|
|
|
@ -12,8 +12,10 @@ use crate::{
|
||||||
teams::Team,
|
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)]
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = projects)]
|
#[diesel(table_name = projects)]
|
||||||
#[diesel(belongs_to(Team))]
|
#[diesel(belongs_to(Team))]
|
||||||
|
@ -59,17 +61,17 @@ impl Project {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
projects::id.eq(project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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)
|
projects::team_id.eq(team_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn with_name(name: String) -> _ {
|
pub fn with_name<'a>(name: &'a str) -> _ {
|
||||||
projects::name.eq(name)
|
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())
|
||||||
|
}
|
840
src/router.rs
840
src/router.rs
|
@ -1,856 +1,38 @@
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use askama_axum::Template;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::{get, post},
|
routing::get,
|
||||||
Router,
|
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 tower_http::services::{ServeDir, ServeFile};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_keys::ApiKey,
|
app_state::AppState, auth, channels_router, projects_router, settings::Settings, teams_router,
|
||||||
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,
|
|
||||||
v0_router,
|
v0_router,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERIFICATION_CODE_LEN: usize = 6;
|
|
||||||
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
|
||||||
|
|
||||||
pub fn new_router(state: AppState) -> Router<()> {
|
pub fn new_router(state: AppState) -> Router<()> {
|
||||||
let base_path = state.settings.base_path.clone();
|
let base_path = state.settings.base_path.clone();
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(landing_page))
|
.route("/", get(landing_page))
|
||||||
.merge(v0_router::new_router(state.clone()))
|
.merge(channels_router::new_router())
|
||||||
.route("/teams", get(teams_page))
|
.merge(projects_router::new_router())
|
||||||
.route("/teams/{team_id}", get(team_page))
|
.merge(teams_router::new_router())
|
||||||
.route("/teams/{team_id}/projects", get(projects_page))
|
.merge(v0_router::new_router())
|
||||||
.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))
|
|
||||||
.nest("/auth", auth::new_router())
|
.nest("/auth", auth::new_router())
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
|
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
|
||||||
)
|
)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let app = {
|
if base_path.is_empty() {
|
||||||
if base_path == "" {
|
|
||||||
app
|
app
|
||||||
} else {
|
} else {
|
||||||
Router::new().nest(&base_path, app).fallback_service(
|
Router::new().nest(&base_path, app).fallback_service(
|
||||||
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
|
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
app
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn landing_page(State(state): State<AppState>) -> impl IntoResponse {
|
async fn landing_page(State(Settings { base_path, .. }): State<Settings>) -> impl IntoResponse {
|
||||||
Redirect::to(&format!("{}/teams", state.settings.base_path))
|
Redirect::to(&format!("{}/teams", 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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())
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ impl PgStore {
|
||||||
impl std::fmt::Debug for PgStore {
|
impl std::fmt::Debug for PgStore {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "PgStore")?;
|
write!(f, "PgStore")?;
|
||||||
Ok(()).into()
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ impl SessionStore for PgStore {
|
||||||
async fn store_session(&self, session: Session) -> Result<Option<String>> {
|
async fn store_session(&self, session: Session) -> Result<Option<String>> {
|
||||||
let serialized_data = serde_json::to_string(&session)?;
|
let serialized_data = serde_json::to_string(&session)?;
|
||||||
let session_id = session.id().to_string();
|
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?;
|
let conn = self.pool.get().await?;
|
||||||
conn.interact(move |conn| {
|
conn.interact(move |conn| {
|
||||||
diesel::insert_into(browser_sessions::table)
|
diesel::insert_into(browser_sessions::table)
|
||||||
|
|
|
@ -13,9 +13,7 @@ pub struct Settings {
|
||||||
|
|
||||||
pub database_url: String,
|
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>,
|
pub run_database_migrations: Option<u8>,
|
||||||
|
|
||||||
#[serde(default = "default_host")]
|
#[serde(default = "default_host")]
|
||||||
|
@ -106,7 +104,7 @@ impl Settings {
|
||||||
.add_source(Environment::default().separator("__"))
|
.add_source(Environment::default().separator("__"))
|
||||||
.build()
|
.build()
|
||||||
.context("config error")?;
|
.context("config error")?;
|
||||||
Ok(s.try_deserialize().context("deserialize error")?)
|
s.try_deserialize().context("deserialize error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
use diesel::{
|
use diesel::{
|
||||||
dsl::{AsSelect, Eq},
|
dsl::{auto_type, AsSelect},
|
||||||
pg::Pg,
|
pg::Pg,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
schema::{self, team_memberships::dsl::*},
|
schema::{team_memberships, teams, users},
|
||||||
teams::Team,
|
teams::Team,
|
||||||
users::User,
|
users::User,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = schema::team_memberships)]
|
#[diesel(table_name = team_memberships)]
|
||||||
#[diesel(belongs_to(crate::teams::Team))]
|
|
||||||
#[diesel(belongs_to(crate::users::User))]
|
|
||||||
#[diesel(primary_key(team_id, user_id))]
|
#[diesel(primary_key(team_id, user_id))]
|
||||||
#[diesel(check_for_backend(Pg))]
|
#[diesel(check_for_backend(Pg))]
|
||||||
pub struct TeamMembership {
|
pub struct TeamMembership {
|
||||||
|
@ -26,17 +24,19 @@ impl TeamMembership {
|
||||||
#[diesel::dsl::auto_type(no_type_alias)]
|
#[diesel::dsl::auto_type(no_type_alias)]
|
||||||
pub fn all() -> _ {
|
pub fn all() -> _ {
|
||||||
let select: AsSelect<(Team, User), Pg> = <(Team, User)>::as_select();
|
let select: AsSelect<(Team, User), Pg> = <(Team, User)>::as_select();
|
||||||
team_memberships
|
team_memberships::table
|
||||||
.inner_join(schema::teams::table)
|
.inner_join(teams::table)
|
||||||
.inner_join(schema::users::table)
|
.inner_join(users::table)
|
||||||
.select(select)
|
.select(select)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_team_id(team_id_value: Uuid) -> Eq<team_id, Uuid> {
|
#[auto_type(no_type_alias)]
|
||||||
team_id.eq(team_id_value)
|
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> {
|
#[auto_type(no_type_alias)]
|
||||||
user_id.eq(user_id_value)
|
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},
|
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)]
|
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = teams)]
|
#[diesel(table_name = teams)]
|
||||||
#[diesel(check_for_backend(Pg))]
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
@ -26,10 +29,9 @@ impl Team {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[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 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(&self.id);
|
||||||
let filter: Eq<api_keys::team_id, Uuid> = ApiKey::with_team(id);
|
|
||||||
all.filter(filter)
|
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 anyhow::Context;
|
||||||
use axum::{
|
use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt};
|
||||||
extract::FromRequestParts,
|
|
||||||
http::request::Parts,
|
|
||||||
response::{IntoResponse, Redirect, Response},
|
|
||||||
RequestPartsExt,
|
|
||||||
};
|
|
||||||
use diesel::{
|
use diesel::{
|
||||||
associations::Identifiable,
|
associations::Identifiable,
|
||||||
deserialize::Queryable,
|
deserialize::Queryable,
|
||||||
|
@ -38,15 +33,15 @@ impl User {
|
||||||
users::table.select(User::as_select())
|
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)
|
users::uid.eq(uid_value)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn team_memberships(self) -> _ {
|
pub fn team_memberships(&self) -> _ {
|
||||||
let user_id: Uuid = self.id.clone();
|
let user_id_filter: Eq<team_memberships::user_id, &Uuid> =
|
||||||
let user_id_filter: Eq<team_memberships::user_id, Uuid> =
|
TeamMembership::with_user_id(&self.id);
|
||||||
TeamMembership::with_user_id(user_id);
|
|
||||||
let select: AsSelect<(TeamMembership, Team), Pg> = <(TeamMembership, Team)>::as_select();
|
let select: AsSelect<(TeamMembership, Team), Pg> = <(TeamMembership, Team)>::as_select();
|
||||||
team_memberships::table
|
team_memberships::table
|
||||||
.inner_join(teams::table)
|
.inner_join(teams::table)
|
||||||
|
@ -59,7 +54,7 @@ impl User {
|
||||||
pub struct CurrentUser(pub User);
|
pub struct CurrentUser(pub User);
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for CurrentUser {
|
impl FromRequestParts<AppState> for CurrentUser {
|
||||||
type Rejection = CurrentUserRejection;
|
type Rejection = AppError;
|
||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
|
@ -68,12 +63,13 @@ impl FromRequestParts<AppState> for CurrentUser {
|
||||||
let auth_info = parts
|
let auth_info = parts
|
||||||
.extract_with_state::<AuthInfo, AppState>(state)
|
.extract_with_state::<AuthInfo, AppState>(state)
|
||||||
.await
|
.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
|
let current_user = state
|
||||||
.db_pool
|
.db_pool
|
||||||
.get()
|
.get()
|
||||||
.await
|
.await?
|
||||||
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?
|
|
||||||
.interact(move |conn| {
|
.interact(move |conn| {
|
||||||
let maybe_current_user = User::all()
|
let maybe_current_user = User::all()
|
||||||
.filter(User::with_uid(&auth_info.sub))
|
.filter(User::with_uid(&auth_info.sub))
|
||||||
|
@ -111,24 +107,7 @@ impl FromRequestParts<AppState> for CurrentUser {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()?;
|
||||||
.map_err(|err| CurrentUserRejection::InternalServerError(err.into()))?;
|
|
||||||
Ok(CurrentUser(current_user))
|
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> =
|
static RE_PROJECT_NAME: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"^[a-z0-9_-]{1,100}$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[a-z0-9_-]{1,100}$").unwrap());
|
||||||
|
|
||||||
pub fn new_router(state: AppState) -> Router<AppState> {
|
pub fn new_router() -> Router<AppState> {
|
||||||
Router::new().route("/say", get(say_get)).with_state(state)
|
Router::new().route("/say", get(say_get))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
|
@ -72,7 +72,7 @@ async fn say_get(
|
||||||
)))?;
|
)))?;
|
||||||
db_conn
|
db_conn
|
||||||
.interact::<_, Result<ApiKey, AppError>>(move |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))
|
.set(api_keys::last_used_at.eq(diesel::dsl::now))
|
||||||
.returning(ApiKey::as_returning())
|
.returning(ApiKey::as_returning())
|
||||||
.get_result(conn)
|
.get_result(conn)
|
||||||
|
@ -91,8 +91,8 @@ async fn say_get(
|
||||||
conn.transaction(move |conn| {
|
conn.transaction(move |conn| {
|
||||||
Ok(
|
Ok(
|
||||||
match Project::all()
|
match Project::all()
|
||||||
.filter(Project::with_team(api_key.team_id))
|
.filter(Project::with_team(&api_key.team_id))
|
||||||
.filter(Project::with_name(project_name.clone()))
|
.filter(Project::with_name(&project_name))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()
|
.optional()
|
||||||
.context("failed to load project")?
|
.context("failed to load project")?
|
||||||
|
@ -109,14 +109,14 @@ async fn say_get(
|
||||||
};
|
};
|
||||||
|
|
||||||
let team_governor = {
|
let team_governor = {
|
||||||
let team_id = project.team_id.clone();
|
let team_id = project.team_id;
|
||||||
db_conn
|
db_conn
|
||||||
.interact::<_, Result<Governor, AppError>>(move |conn| {
|
.interact::<_, Result<Governor, AppError>>(move |conn| {
|
||||||
// TODO: extract this logic to a method in crate::governors,
|
// TODO: extract this logic to a method in crate::governors,
|
||||||
// and create governor proactively on team creation
|
// and create governor proactively on team creation
|
||||||
match Governor::all()
|
match Governor::all()
|
||||||
.filter(Governor::with_team(team_id.clone()))
|
.filter(Governor::with_team(&team_id))
|
||||||
.filter(Governor::with_project(None))
|
.filter(Governor::with_project(&None))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
{
|
{
|
||||||
diesel::QueryResult::Ok(governor) => Ok(governor),
|
diesel::QueryResult::Ok(governor) => Ok(governor),
|
||||||
|
|
|
@ -15,7 +15,7 @@ use crate::{
|
||||||
pub async fn run_worker(state: AppState) -> Result<()> {
|
pub async fn run_worker(state: AppState) -> Result<()> {
|
||||||
async move {
|
async move {
|
||||||
process_messages(state.clone()).await?;
|
process_messages(state.clone()).await?;
|
||||||
reclaim_governor_entries(state.clone()).await?;
|
reclaim_governor_entries(state).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
.instrument(tracing::debug_span!("run_worker()"))
|
.instrument(tracing::debug_span!("run_worker()"))
|
||||||
|
@ -62,13 +62,13 @@ async fn process_messages(state: AppState) -> Result<()> {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let email = crate::email::Message {
|
let email = crate::email::Message {
|
||||||
from: state.settings.email.message_from.clone().into(),
|
from: state.settings.email.message_from.clone(),
|
||||||
to: recipient.into(),
|
to: recipient.into(),
|
||||||
subject: "Shout".to_string(),
|
subject: "Shout".to_string(),
|
||||||
text_body: message.message.clone(),
|
text_body: message.message.clone(),
|
||||||
};
|
};
|
||||||
tracing::debug!("Sending email to recipient for channel {}", channel.id);
|
tracing::debug!("Sending email to recipient for channel {}", channel.id);
|
||||||
Some((message.id.clone(), email))
|
Some((message.id, email))
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Email recipient for channel {} is not verified",
|
"Email recipient for channel {} is not verified",
|
||||||
|
@ -82,7 +82,7 @@ async fn process_messages(state: AppState) -> Result<()> {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if !emails.is_empty() {
|
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
|
let results = state
|
||||||
.mailer
|
.mailer
|
||||||
.send_batch(emails.into_iter().map(|(_, email)| email).collect())
|
.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 fn reclaim_governor_entries(state: AppState) -> Result<()> {
|
||||||
async move {
|
async move {
|
||||||
let db_conn = state.db_pool.get().await?;
|
let db_conn = state.db_pool.get().await?;
|
||||||
db_conn
|
db_conn.interact(Governor::reclaim_all).await.unwrap()?;
|
||||||
.interact(move |conn| Governor::reclaim_all(conn))
|
|
||||||
.await
|
|
||||||
.unwrap()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// This doesn't do much, since it seems that tracing spans don't carry
|
// This doesn't do much, since it seems that tracing spans don't carry
|
||||||
|
|
Loading…
Add table
Reference in a new issue