1
0
Fork 0
forked from 2sys/shoutdotdev

consolidate channel backend configs in jsonb col

This commit is contained in:
Brent Schroeter 2025-02-26 13:10:27 -08:00
parent f096179d56
commit e30c610de4
14 changed files with 466 additions and 397 deletions

1
Cargo.lock generated
View file

@ -758,6 +758,7 @@ dependencies = [
"diesel_derives", "diesel_derives",
"itoa", "itoa",
"pq-sys", "pq-sys",
"serde_json",
"uuid", "uuid",
] ]

View file

@ -28,7 +28,7 @@ axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
chrono = { version = "0.4.39", features = ["serde"] } chrono = { version = "0.4.39", features = ["serde"] }
base64 = "0.22.1" base64 = "0.22.1"
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] } diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid", "serde_json"] }
tower = "0.5.2" tower = "0.5.2"
regex = "1.11.1" regex = "1.11.1"
lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] } lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] }

View file

@ -1,3 +1,5 @@
DROP TABLE IF EXISTS channel_selections;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS api_keys; DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS team_memberships; DROP TABLE IF EXISTS team_memberships;

View file

@ -41,3 +41,19 @@ CREATE TABLE projects (
UNIQUE (team_id, name) UNIQUE (team_id, name)
); );
CREATE INDEX ON projects(team_id); CREATE INDEX ON projects(team_id);
CREATE TABLE IF NOT EXISTS channels (
id UUID NOT NULL PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
enable_by_default BOOLEAN NOT NULL DEFAULT FALSE,
backend_config JSONB NOT NULL DEFAULT '{}'::JSONB
);
CREATE TABLE IF NOT EXISTS channel_selections (
project_id UUID NOT NULL REFERENCES projects(id),
channel_id UUID NOT NULL REFERENCES channels(id),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX ON channel_selections (project_id);
CREATE INDEX ON channel_selections (channel_id);

View file

@ -1,8 +1,10 @@
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL,
project_id UUID NOT NULL REFERENCES projects (id), channel_id UUID NOT NULL REFERENCES channels (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ,
message TEXT NOT NULL message TEXT NOT NULL
); );
CREATE INDEX ON messages (project_id); CREATE INDEX ON messages (channel_id);
CREATE INDEX ON messages (created_at); CREATE INDEX ON messages (created_at);
CREATE INDEX ON messages (sent_at);

View file

@ -1,4 +0,0 @@
DROP TABLE IF EXISTS channel_selections;
DROP TABLE IF EXISTS slack_channels;
DROP TABLE IF EXISTS email_channels;
DROP TABLE IF EXISTS channels;

View file

@ -1,29 +0,0 @@
CREATE TABLE IF NOT EXISTS channels (
id UUID NOT NULL PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
enable_by_default BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS email_channels (
id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
recipient TEXT NOT NULL DEFAULT '',
verification_code TEXT NOT NULL DEFAULT '',
verification_code_guesses INT NOT NULL DEFAULT 0,
verified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS slack_channels (
id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
oauth_state TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS channel_selections (
project_id UUID NOT NULL REFERENCES projects(id),
channel_id UUID NOT NULL REFERENCES channels(id),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX ON channel_selections (project_id);
CREATE INDEX ON channel_selections (channel_id);

View file

@ -1,64 +1,44 @@
use anyhow::Context;
use deadpool_diesel::postgres::Connection;
use diesel::{ use diesel::{
dsl::{auto_type, insert_into, AsSelect}, backend::Backend,
deserialize::{self, FromSql, FromSqlRow},
dsl::{auto_type, AsSelect},
expression::AsExpression,
pg::Pg, pg::Pg,
prelude::*, prelude::*,
Connection as _, serialize::{Output, ToSql},
sql_types::Jsonb,
}; };
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{schema::channels, teams::Team};
app_error::AppError,
schema::{channels, email_channels, slack_channels},
teams::Team,
};
pub const CHANNEL_BACKEND_EMAIL: &'static str = "email";
pub const CHANNEL_BACKEND_SLACK: &'static str = "slack";
/**
* Represents a target/destination for messages, with the sender configuration
* defined in the backend_config field. A single channel may be attached to
* (in other words, "enabled" or "selected" for) any number of projects within
* the same team.
*/
#[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))]
pub struct Channel { pub struct Channel {
pub id: Uuid, pub id: Uuid,
pub team_id: Uuid, pub team_id: Uuid,
pub name: String, pub name: String,
pub enable_by_default: bool, pub enable_by_default: bool,
pub backend_config: BackendConfig,
#[diesel(embed)]
pub email_data: Option<EmailChannel>,
#[diesel(embed)]
pub slack_data: Option<SlackChannel>,
}
#[derive(
Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable, Serialize,
)]
#[diesel(belongs_to(Channel, foreign_key = id))]
pub struct EmailChannel {
pub id: Uuid,
pub recipient: String,
#[serde(skip_serializing)]
pub verification_code: String,
pub verification_code_guesses: i32,
pub verified: bool,
}
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(belongs_to(Channel, foreign_key = id))]
pub struct SlackChannel {
pub id: Uuid,
pub oauth_state: String,
pub access_token: String,
pub conversation_id: String,
} }
impl Channel { impl Channel {
#[auto_type(no_type_alias)] #[auto_type(no_type_alias)]
pub fn all() -> _ { pub fn all() -> _ {
let select: AsSelect<Channel, Pg> = Channel::as_select(); let select: AsSelect<Channel, Pg> = Channel::as_select();
channels::table channels::table.select(select)
.left_join(email_channels::table)
.left_join(slack_channels::table)
.select(select)
} }
#[auto_type(no_type_alias)] #[auto_type(no_type_alias)]
@ -70,31 +50,123 @@ impl Channel {
pub fn with_team(team_id: Uuid) -> _ { pub fn with_team(team_id: Uuid) -> _ {
channels::team_id.eq(team_id) channels::team_id.eq(team_id)
} }
}
pub async fn create_email_channel( /**
db_conn: &Connection, * Encapsulates any information that needs to be persisted for setting up or
team_id: Uuid, * using a channel's backend (that is, email sender, Slack app, etc.). This
) -> Result<Self, AppError> { * configuration is encoded to a jsonb column in the database, which determines
let id = Uuid::now_v7(); * the channel type along with configuration details.
db_conn *
.interact(move |conn| { * Note: In a previous implementation, channel configuration was handled by
conn.transaction(move |conn| { * creating a dedicated table for each channel type and joining them to the
insert_into(channels::table) * `channels` table in order to access configuration fields. The jsonb approach
.values(( * simplifies database management and lends itself to a cleaner Rust
channels::id.eq(id.clone()), * implementation in which this enum can be treated as a column type with
channels::team_id.eq(team_id), * enforcement of data structure invariants handled entirely in the to_sql()
channels::name.eq("Untitled Email Channel"), * and from_sql() serialization/deserialization logic.
*/
#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)]
#[diesel(sql_type = Jsonb)]
pub enum BackendConfig {
Email(EmailBackendConfig),
Slack(SlackBackendConfig),
}
impl ToSql<Jsonb, Pg> for BackendConfig {
fn to_sql<'a>(&self, out: &mut Output<'a, '_, Pg>) -> diesel::serialize::Result {
match self.clone() {
BackendConfig::Email(config) => ToSql::<Jsonb, Pg>::to_sql(
&json!({
"t": CHANNEL_BACKEND_EMAIL,
"config": config,
}),
&mut out.reborrow(),
),
BackendConfig::Slack(config) => ToSql::<Jsonb, Pg>::to_sql(
&json!({
"t": CHANNEL_BACKEND_SLACK,
"config": config,
}),
&mut out.reborrow(),
),
}
}
}
impl FromSql<Jsonb, Pg> for BackendConfig {
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
let value = <serde_json::Value as FromSql<Jsonb, Pg>>::from_sql(bytes)?;
#[derive(Deserialize)]
struct IntermediateBackendConfig {
pub t: String,
pub config: serde_json::Value,
}
let intermediate_value = IntermediateBackendConfig::deserialize(value)?;
match intermediate_value.t.as_str() {
CHANNEL_BACKEND_EMAIL => Ok(BackendConfig::Email(EmailBackendConfig::deserialize(
intermediate_value.config,
)?)),
CHANNEL_BACKEND_SLACK => Ok(BackendConfig::Slack(SlackBackendConfig::deserialize(
intermediate_value.config,
)?)),
t => Err(anyhow::anyhow!("backend type not recognized: {}", t).into()),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct EmailBackendConfig {
pub recipient: String,
pub verification_code: String,
pub verification_code_guesses: u32,
pub verified: bool,
}
impl TryFrom<BackendConfig> for EmailBackendConfig {
type Error = anyhow::Error;
fn try_from(value: BackendConfig) -> Result<Self, Self::Error> {
if let BackendConfig::Email(config) = value {
Ok(config)
} else {
Err(anyhow::anyhow!(
"could not load backend config as email channel"
)) ))
.execute(conn)?; }
insert_into(email_channels::table) }
.values(email_channels::id.eq(id.clone())) }
.execute(conn)?;
Self::all().filter(Self::with_id(id)).first(conn) impl Into<BackendConfig> for EmailBackendConfig {
}) fn into(self) -> BackendConfig {
}) BackendConfig::Email(self)
.await }
.unwrap() }
.context("Failed to insert new EmailChannel.")
.map_err(|err| err.into()) #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SlackBackendConfig {
pub oauth_state: String,
pub access_token: String,
pub refresh_token: String,
pub conversation_id: String,
}
impl TryFrom<BackendConfig> for SlackBackendConfig {
type Error = anyhow::Error;
fn try_from(value: BackendConfig) -> Result<Self, Self::Error> {
if let BackendConfig::Slack(config) = value {
Ok(config)
} else {
Err(anyhow::anyhow!(
"could not load backend config as slack channel"
))
}
}
}
impl Into<BackendConfig> for SlackBackendConfig {
fn into(self) -> BackendConfig {
BackendConfig::Slack(self)
} }
} }

View file

@ -6,14 +6,14 @@ use diesel::{
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{projects::Project, schema::messages}; use crate::{channels::Channel, schema::messages};
#[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(Project))] #[diesel(belongs_to(Channel))]
pub struct Message { pub struct Message {
pub id: Uuid, pub id: Uuid,
pub project_id: Uuid, pub channel_id: Uuid,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub message: String, pub message: String,
} }
@ -26,17 +26,7 @@ impl Message {
} }
#[auto_type(no_type_alias)] #[auto_type(no_type_alias)]
pub fn with_project(project_id: Uuid) -> _ { pub fn with_channel(channel_id: Uuid) -> _ {
messages::project_id.eq(project_id) messages::channel_id.eq(channel_id)
}
#[auto_type(no_type_alias)]
pub fn values_now(project_id: Uuid, message: String) -> _ {
let id: Uuid = Uuid::now_v7();
(
messages::id.eq(id),
messages::project_id.eq(project_id),
messages::message.eq(message),
)
} }
} }

View file

@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::{ use crate::{
channels::Channel, channels::Channel,
schema::{channel_selections, channels, email_channels, projects, slack_channels}, schema::{channel_selections, channels, projects},
teams::Team, teams::Team,
}; };
@ -48,8 +48,6 @@ impl Project {
let project_filter: Eq<channel_selections::project_id, Uuid> = let project_filter: Eq<channel_selections::project_id, Uuid> =
channel_selections::project_id.eq(self.id); channel_selections::project_id.eq(self.id);
channels::table channels::table
.left_join(email_channels::table)
.left_join(slack_channels::table)
.inner_join(channel_selections::table) .inner_join(channel_selections::table)
.filter(project_filter) .filter(project_filter)
.select(select) .select(select)

View file

@ -27,13 +27,13 @@ use crate::{
app_state::{AppState, DbConn}, app_state::{AppState, DbConn},
auth, auth,
channel_selections::ChannelSelection, channel_selections::ChannelSelection,
channels::Channel, channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
csrf::generate_csrf_token, csrf::generate_csrf_token,
email::{MailSender as _, Mailer}, email::{MailSender as _, Mailer},
guards, guards,
nav_state::{Breadcrumb, NavState}, nav_state::{Breadcrumb, NavState},
projects::Project, projects::Project,
schema::{self, channel_selections, channels, email_channels}, schema::{self, channel_selections, channels},
settings::Settings, settings::Settings,
team_memberships::TeamMembership, team_memberships::TeamMembership,
teams::Team, teams::Team,
@ -41,6 +41,9 @@ use crate::{
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();
Router::new().nest( Router::new().nest(
@ -107,7 +110,7 @@ async fn teams_page(
.set_base_path(&base_path) .set_base_path(&base_path)
.push_slug(Breadcrumb { .push_slug(Breadcrumb {
href: "teams".to_string(), href: "teams".to_string(),
label: "New Team".to_string(), label: "Teams".to_string(),
}) })
.set_navbar_active_item("teams"); .set_navbar_active_item("teams");
#[derive(Template)] #[derive(Template)]
@ -339,8 +342,24 @@ async fn post_new_channel(
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?; let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?; guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
let channel_id = Uuid::now_v7();
let channel = match form_body.channel_type.as_str() { let channel = match form_body.channel_type.as_str() {
"email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?, 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( return Err(AppError::BadRequestError(
"Channel type not recognized.".to_string(), "Channel type not recognized.".to_string(),
@ -399,7 +418,8 @@ async fn channel_page(
}) })
.set_navbar_active_item("channels"); .set_navbar_active_item("channels");
if channel.email_data.is_some() { match channel.backend_config {
BackendConfig::Email(_) => {
#[derive(Template)] #[derive(Template)]
#[template(path = "channel-email.html")] #[template(path = "channel-email.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -417,13 +437,10 @@ async fn channel_page(
} }
.render()?, .render()?,
)) ))
} else if channel.slack_data.is_some() { }
BackendConfig::Slack(_) => {
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
} else { }
Err(anyhow::anyhow!(
"Channel doesn't have a recognized variant for which to render a config page."
)
.into())
} }
} }
@ -473,6 +490,28 @@ async fn update_channel(
.into_response()) .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(
conn: &mut PgConnection,
team_id: Uuid,
channel_id: 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)] #[derive(Deserialize)]
struct UpdateChannelEmailRecipientFormBody { struct UpdateChannelEmailRecipientFormBody {
// Yes it's a mouthful, but it's only used twice // Yes it's a mouthful, but it's only used twice
@ -503,49 +542,41 @@ async fn update_channel_email_recipient(
let verification_code: String = rand::thread_rng() let verification_code: String = rand::thread_rng()
.sample_iter(&Uniform::try_from(0..9).unwrap()) .sample_iter(&Uniform::try_from(0..9).unwrap())
.take(6) .take(VERIFICATION_CODE_LEN)
.map(|n| n.to_string()) .map(|n| n.to_string())
.collect(); .collect();
let verification_code_copy = verification_code.clone();
let recipient_copy = form_body.recipient.clone();
let channel_id_filter = Channel::with_id(channel_id.clone());
let email_channel_id_filter = email_channels::id.eq(channel_id.clone());
let team_filter = Channel::with_team(team_id.clone());
let updated_rows = db_conn
.interact(move |conn| {
if Channel::all()
.filter(channel_id_filter)
.filter(team_filter)
.filter(email_channel_id_filter.clone())
.first(conn)
.optional()
.context("failed to check whether channel exists under team")
.map_err(Into::<AppError>::into)?
.is_none()
{ {
return Err(AppError::NotFoundError( let verification_code = verification_code.clone();
"Channel with that team and ID not found.".to_string(), 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());
} }
update(email_channels::table.filter(email_channel_id_filter)) Ok(())
.set(( })
email_channels::recipient.eq(recipient_copy),
email_channels::verification_code.eq(verification_code_copy),
email_channels::verification_code_guesses.eq(0),
))
.execute(conn)
.map_err(Into::<AppError>::into)
}) })
.await .await
.unwrap()?; .unwrap()?;
if updated_rows != 1 {
return Err(anyhow!(
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
updated_rows
)
.into());
} }
tracing::debug!( tracing::debug!(
@ -600,48 +631,54 @@ async fn verify_email(
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?; guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?; guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let channel_id_filter = Channel::with_id(channel_id.clone()); if form_body.code.len() != VERIFICATION_CODE_LEN {
let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); return Err(AppError::BadRequestError(format!(
let team_filter = Channel::with_team(team_id.clone()); "Verification code must be {} characters long.",
let updated_rows = db_conn VERIFICATION_CODE_LEN
.interact(move |conn| { )));
if Channel::all() }
.filter(channel_id_filter)
.filter(team_filter)
.filter(email_channel_id_filter.clone())
.first(conn)
.optional()
.context("failed to check whether channel exists under team")
.map_err(Into::<AppError>::into)?
.is_none()
{ {
return Err(AppError::NotFoundError( let channel_id = channel_id.clone();
"Channel with that team and ID not found.".to_string(), 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(),
)); ));
} }
update(email_channels::table.filter(email_channel_id_filter)) if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
.set( return Err(AppError::BadRequestError(
email_channels::verification_code_guesses "Verification expired.".to_string(),
.eq(email_channels::verification_code_guesses + 1), ));
) }
.execute(conn) let new_config = if config.verification_code == verification_code {
.map_err(Into::<AppError>::into)?; EmailBackendConfig {
update( verified: true,
email_channels::table verification_code: "".to_string(),
.filter(email_channel_id_filter) verification_code_guesses: 0,
.filter(email_channels::verification_code.eq(form_body.code)), ..config
) }
.set(email_channels::verified.eq(true)) } else {
.execute(conn) EmailBackendConfig {
.map_err(Into::<AppError>::into) 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 .await
.unwrap()?; .unwrap()?;
if updated_rows != 1 { };
return Err(AppError::BadRequestError(
"Verification code not accepted.".to_string(),
));
}
Ok(Redirect::to(&format!( Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}", "{}/teams/{}/channels/{}",

View file

@ -31,6 +31,7 @@ diesel::table! {
team_id -> Uuid, team_id -> Uuid,
name -> Text, name -> Text,
enable_by_default -> Bool, enable_by_default -> Bool,
backend_config -> Jsonb,
} }
} }
@ -42,21 +43,12 @@ diesel::table! {
} }
} }
diesel::table! {
email_channels (id) {
id -> Uuid,
recipient -> Text,
verification_code -> Text,
verification_code_guesses -> Int4,
verified -> Bool,
}
}
diesel::table! { diesel::table! {
messages (id) { messages (id) {
id -> Uuid, id -> Uuid,
project_id -> Uuid, channel_id -> Uuid,
created_at -> Timestamptz, created_at -> Timestamptz,
sent_at -> Nullable<Timestamptz>,
message -> Text, message -> Text,
} }
} }
@ -69,15 +61,6 @@ diesel::table! {
} }
} }
diesel::table! {
slack_channels (id) {
id -> Uuid,
oauth_state -> Text,
access_token -> Text,
conversation_id -> Text,
}
}
diesel::table! { diesel::table! {
team_memberships (team_id, user_id) { team_memberships (team_id, user_id) {
team_id -> Uuid, team_id -> Uuid,
@ -106,10 +89,8 @@ diesel::joinable!(channel_selections -> channels (channel_id));
diesel::joinable!(channel_selections -> projects (project_id)); diesel::joinable!(channel_selections -> projects (project_id));
diesel::joinable!(channels -> teams (team_id)); diesel::joinable!(channels -> teams (team_id));
diesel::joinable!(csrf_tokens -> users (user_id)); diesel::joinable!(csrf_tokens -> users (user_id));
diesel::joinable!(email_channels -> channels (id)); diesel::joinable!(messages -> channels (channel_id));
diesel::joinable!(messages -> projects (project_id));
diesel::joinable!(projects -> teams (team_id)); diesel::joinable!(projects -> teams (team_id));
diesel::joinable!(slack_channels -> channels (id));
diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> teams (team_id));
diesel::joinable!(team_memberships -> users (user_id)); diesel::joinable!(team_memberships -> users (user_id));
@ -119,10 +100,8 @@ diesel::allow_tables_to_appear_in_same_query!(
channel_selections, channel_selections,
channels, channels,
csrf_tokens, csrf_tokens,
email_channels,
messages, messages,
projects, projects,
slack_channels,
team_memberships, team_memberships,
teams, teams,
users, users,

View file

@ -14,6 +14,7 @@ use crate::{
api_keys::ApiKey, api_keys::ApiKey,
app_error::AppError, app_error::AppError,
app_state::{AppState, DbConn}, app_state::{AppState, DbConn},
channels::{Channel, EmailBackendConfig},
email::{MailSender as _, Mailer}, email::{MailSender as _, Mailer},
projects::Project, projects::Project,
schema::{api_keys, projects}, schema::{api_keys, projects},
@ -40,56 +41,59 @@ async fn say_get(
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>, Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let key = query.key.clone(); // TODO: do some validation of message contents
let maybe_api_key = db_conn let api_key = {
.interact(move |conn| { let query_key = query.key.clone();
update(api_keys::table.filter(ApiKey::with_id(key))) db_conn
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
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)
.optional() .optional()
.context("failed to get API key")?
.ok_or(AppError::ForbiddenError("Key not accepted.".to_string()))
}) })
.await .await
.unwrap() .unwrap()?
.context("unable to get API key")?;
let api_key = match maybe_api_key {
Some(api_key) => api_key,
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
}; };
let selected_channels = {
let project_name = query.project.to_lowercase(); let project_name = query.project.to_lowercase();
let selected_channels = db_conn db_conn
.interact(move |conn| { .interact::<_, Result<Vec<Channel>, AppError>>(move |conn| {
insert_into(projects::table) conn.transaction(move |conn| {
let project = match Project::all()
.filter(Project::with_team(api_key.team_id))
.filter(Project::with_name(project_name.clone()))
.first(conn)
.optional()
.context("failed to load project")?
{
Some(project) => project,
None => insert_into(projects::table)
.values(( .values((
projects::id.eq(Uuid::now_v7()), projects::id.eq(Uuid::now_v7()),
projects::team_id.eq(api_key.team_id.clone()), projects::team_id.eq(api_key.team_id),
projects::name.eq(project_name.clone()), projects::name.eq(project_name),
)) ))
.on_conflict((projects::team_id, projects::name)) .get_result(conn)
.do_nothing() .context("failed to insert project")?,
.execute(conn) };
.context("failed to insert project")?; Ok(project
// It would be nice to merge these two database operations into one,
// but it's not trivial to do so without faking an update; refer to:
// https://stackoverflow.com/a/42217872
let project = Project::all()
.filter(Project::with_team(api_key.team_id))
.filter(Project::with_name(project_name))
.first(conn)
.context("failed to load project")?;
project
.selected_channels() .selected_channels()
.load(conn) .load(conn)
.context("failed to load selected channels") .context("failed to load selected channels")?)
})
}) })
.await .await
.unwrap() .unwrap()?
.context("unable to get project")?; };
for channel in selected_channels { for channel in selected_channels {
if let Some(email_data) = channel.email_data { if let Ok(config) = TryInto::<EmailBackendConfig>::try_into(channel.backend_config) {
if email_data.verified { if config.verified {
let recipient: lettre::message::Mailbox = email_data.recipient.parse()?; let recipient: lettre::message::Mailbox = config.recipient.parse()?;
let email = crate::email::Message { let email = crate::email::Message {
from: email_settings.message_from.clone().into(), from: email_settings.message_from.clone().into(),
to: recipient.into(), to: recipient.into(),

View file

@ -3,7 +3,7 @@
{% block title %}Shout.dev: Channels{% endblock %} {% block title %}Shout.dev: Channels{% endblock %}
{% block main %} {% block main %}
{% let email_data = channel.email_data.clone().unwrap() %} {% if let BackendConfig::Email(email_data) = channel.backend_config %}
{% include "breadcrumbs.html" %} {% include "breadcrumbs.html" %}
<main class="container mt-5"> <main class="container mt-5">
<section class="mb-4"> <section class="mb-4">
@ -122,4 +122,5 @@
</section> </section>
{% endif %} {% endif %}
</main> </main>
{% endif %}
{% endblock %} {% endblock %}