Compare commits

..

2 commits

Author SHA1 Message Date
Brent Schroeter
962bdcb24f consolidate channel backend configs in jsonb col 2025-02-26 13:09:21 -08:00
Brent Schroeter
899e2c58da update readme 2025-02-22 14:25:00 -08:00
15 changed files with 472 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

@ -3,3 +3,9 @@
Shout.dev is a tool for small software teams to stay in touch with the software Shout.dev is a tool for small software teams to stay in touch with the software
they write. Once set up, it provides an extremely simple HTTP interface through they write. Once set up, it provides an extremely simple HTTP interface through
which to broadcast messages via email and other means of communication. which to broadcast messages via email and other means of communication.
## Project Status
Shout.dev is in the early prototyping stage. Basic documentation and testing
will be prioritized after core functionality is completed and shown to have
practical value.

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 %}