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",
"itoa",
"pq-sys",
"serde_json",
"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"] }
chrono = { version = "0.4.39", features = ["serde"] }
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"
regex = "1.11.1"
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
they write. Once set up, it provides an extremely simple HTTP interface through
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 api_keys;
DROP TABLE IF EXISTS team_memberships;

View file

@ -41,3 +41,19 @@ CREATE TABLE projects (
UNIQUE (team_id, name)
);
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 (
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(),
sent_at TIMESTAMPTZ,
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 (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::{
dsl::{auto_type, insert_into, AsSelect},
backend::Backend,
deserialize::{self, FromSql, FromSqlRow},
dsl::{auto_type, AsSelect},
expression::AsExpression,
pg::Pg,
prelude::*,
Connection as _,
serialize::{Output, ToSql},
sql_types::Jsonb,
};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::{
app_error::AppError,
schema::{channels, email_channels, slack_channels},
teams::Team,
};
use crate::{schema::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)]
#[diesel(belongs_to(Team))]
#[diesel(check_for_backend(Pg))]
pub struct Channel {
pub id: Uuid,
pub team_id: Uuid,
pub name: String,
pub enable_by_default: bool,
#[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,
pub backend_config: BackendConfig,
}
impl Channel {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Channel, Pg> = Channel::as_select();
channels::table
.left_join(email_channels::table)
.left_join(slack_channels::table)
.select(select)
channels::table.select(select)
}
#[auto_type(no_type_alias)]
@ -70,31 +50,123 @@ impl Channel {
pub fn with_team(team_id: Uuid) -> _ {
channels::team_id.eq(team_id)
}
}
pub async fn create_email_channel(
db_conn: &Connection,
team_id: Uuid,
) -> Result<Self, AppError> {
let id = Uuid::now_v7();
db_conn
.interact(move |conn| {
conn.transaction(move |conn| {
insert_into(channels::table)
.values((
channels::id.eq(id.clone()),
channels::team_id.eq(team_id),
channels::name.eq("Untitled 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)
})
})
.await
.unwrap()
.context("Failed to insert new EmailChannel.")
.map_err(|err| err.into())
/**
* Encapsulates any information that needs to be persisted for setting up or
* using a channel's backend (that is, email sender, Slack app, etc.). This
* configuration is encoded to a jsonb column in the database, which determines
* the channel type along with configuration details.
*
* Note: In a previous implementation, channel configuration was handled by
* creating a dedicated table for each channel type and joining them to the
* `channels` table in order to access configuration fields. The jsonb approach
* simplifies database management and lends itself to a cleaner Rust
* implementation in which this enum can be treated as a column type with
* enforcement of data structure invariants handled entirely in the to_sql()
* and from_sql() serialization/deserialization logic.
*/
#[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"
))
}
}
}
impl Into<BackendConfig> for EmailBackendConfig {
fn into(self) -> BackendConfig {
BackendConfig::Email(self)
}
}
#[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 crate::{projects::Project, schema::messages};
use crate::{channels::Channel, schema::messages};
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = messages)]
#[diesel(belongs_to(Project))]
#[diesel(belongs_to(Channel))]
pub struct Message {
pub id: Uuid,
pub project_id: Uuid,
pub channel_id: Uuid,
pub created_at: DateTime<Utc>,
pub message: String,
}
@ -26,17 +26,7 @@ impl Message {
}
#[auto_type(no_type_alias)]
pub fn with_project(project_id: Uuid) -> _ {
messages::project_id.eq(project_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),
)
pub fn with_channel(channel_id: Uuid) -> _ {
messages::channel_id.eq(channel_id)
}
}

View file

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

View file

@ -27,13 +27,13 @@ use crate::{
app_state::{AppState, DbConn},
auth,
channel_selections::ChannelSelection,
channels::Channel,
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
csrf::generate_csrf_token,
email::{MailSender as _, Mailer},
guards,
nav_state::{Breadcrumb, NavState},
projects::Project,
schema::{self, channel_selections, channels, email_channels},
schema::{self, channel_selections, channels},
settings::Settings,
team_memberships::TeamMembership,
teams::Team,
@ -41,6 +41,9 @@ use crate::{
v0_router,
};
const VERIFICATION_CODE_LEN: usize = 6;
const MAX_VERIFICATION_GUESSES: u32 = 100;
pub fn new_router(state: AppState) -> Router<()> {
let base_path = state.settings.base_path.clone();
Router::new().nest(
@ -107,7 +110,7 @@ async fn teams_page(
.set_base_path(&base_path)
.push_slug(Breadcrumb {
href: "teams".to_string(),
label: "New Team".to_string(),
label: "Teams".to_string(),
})
.set_navbar_active_item("teams");
#[derive(Template)]
@ -339,8 +342,24 @@ async fn post_new_channel(
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
let channel_id = Uuid::now_v7();
let channel = match form_body.channel_type.as_str() {
"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(
"Channel type not recognized.".to_string(),
@ -399,31 +418,29 @@ async fn channel_page(
})
.set_navbar_active_item("channels");
if channel.email_data.is_some() {
#[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,
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,
}
.render()?,
))
} else if channel.slack_data.is_some() {
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())
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())
}
}
}
@ -473,6 +490,28 @@ async fn update_channel(
.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)]
struct UpdateChannelEmailRecipientFormBody {
// 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()
.sample_iter(&Uniform::try_from(0..9).unwrap())
.take(6)
.take(VERIFICATION_CODE_LEN)
.map(|n| n.to_string())
.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(
"Channel with that team and ID not found.".to_string(),
));
}
update(email_channels::table.filter(email_channel_id_filter))
.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
.unwrap()?;
if updated_rows != 1 {
return Err(anyhow!(
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
updated_rows
)
.into());
{
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!(
@ -600,49 +631,55 @@ async fn verify_email(
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let 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(
"Channel with that team and ID not found.".to_string(),
));
}
update(email_channels::table.filter(email_channel_id_filter))
.set(
email_channels::verification_code_guesses
.eq(email_channels::verification_code_guesses + 1),
)
.execute(conn)
.map_err(Into::<AppError>::into)?;
update(
email_channels::table
.filter(email_channel_id_filter)
.filter(email_channels::verification_code.eq(form_body.code)),
)
.set(email_channels::verified.eq(true))
.execute(conn)
.map_err(Into::<AppError>::into)
})
.await
.unwrap()?;
if updated_rows != 1 {
return Err(AppError::BadRequestError(
"Verification code not accepted.".to_string(),
));
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,

View file

@ -31,6 +31,7 @@ diesel::table! {
team_id -> Uuid,
name -> Text,
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! {
messages (id) {
id -> Uuid,
project_id -> Uuid,
channel_id -> Uuid,
created_at -> Timestamptz,
sent_at -> Nullable<Timestamptz>,
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! {
team_memberships (team_id, user_id) {
team_id -> Uuid,
@ -106,10 +89,8 @@ diesel::joinable!(channel_selections -> channels (channel_id));
diesel::joinable!(channel_selections -> projects (project_id));
diesel::joinable!(channels -> teams (team_id));
diesel::joinable!(csrf_tokens -> users (user_id));
diesel::joinable!(email_channels -> channels (id));
diesel::joinable!(messages -> projects (project_id));
diesel::joinable!(messages -> channels (channel_id));
diesel::joinable!(projects -> teams (team_id));
diesel::joinable!(slack_channels -> channels (id));
diesel::joinable!(team_memberships -> teams (team_id));
diesel::joinable!(team_memberships -> users (user_id));
@ -119,10 +100,8 @@ diesel::allow_tables_to_appear_in_same_query!(
channel_selections,
channels,
csrf_tokens,
email_channels,
messages,
projects,
slack_channels,
team_memberships,
teams,
users,

View file

@ -14,6 +14,7 @@ use crate::{
api_keys::ApiKey,
app_error::AppError,
app_state::{AppState, DbConn},
channels::{Channel, EmailBackendConfig},
email::{MailSender as _, Mailer},
projects::Project,
schema::{api_keys, projects},
@ -40,56 +41,59 @@ async fn say_get(
DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> {
let key = query.key.clone();
let maybe_api_key = db_conn
.interact(move |conn| {
update(api_keys::table.filter(ApiKey::with_id(key)))
.set(api_keys::last_used_at.eq(diesel::dsl::now))
.returning(ApiKey::as_returning())
.get_result(conn)
.optional()
})
.await
.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())),
// TODO: do some validation of message contents
let api_key = {
let query_key = query.key.clone();
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))
.returning(ApiKey::as_returning())
.get_result(conn)
.optional()
.context("failed to get API key")?
.ok_or(AppError::ForbiddenError("Key not accepted.".to_string()))
})
.await
.unwrap()?
};
let selected_channels = {
let project_name = query.project.to_lowercase();
db_conn
.interact::<_, Result<Vec<Channel>, AppError>>(move |conn| {
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((
projects::id.eq(Uuid::now_v7()),
projects::team_id.eq(api_key.team_id),
projects::name.eq(project_name),
))
.get_result(conn)
.context("failed to insert project")?,
};
Ok(project
.selected_channels()
.load(conn)
.context("failed to load selected channels")?)
})
})
.await
.unwrap()?
};
let project_name = query.project.to_lowercase();
let selected_channels = db_conn
.interact(move |conn| {
insert_into(projects::table)
.values((
projects::id.eq(Uuid::now_v7()),
projects::team_id.eq(api_key.team_id.clone()),
projects::name.eq(project_name.clone()),
))
.on_conflict((projects::team_id, projects::name))
.do_nothing()
.execute(conn)
.context("failed to insert 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()
.load(conn)
.context("failed to load selected channels")
})
.await
.unwrap()
.context("unable to get project")?;
for channel in selected_channels {
if let Some(email_data) = channel.email_data {
if email_data.verified {
let recipient: lettre::message::Mailbox = email_data.recipient.parse()?;
if let Ok(config) = TryInto::<EmailBackendConfig>::try_into(channel.backend_config) {
if config.verified {
let recipient: lettre::message::Mailbox = config.recipient.parse()?;
let email = crate::email::Message {
from: email_settings.message_from.clone().into(),
to: recipient.into(),

View file

@ -3,123 +3,124 @@
{% block title %}Shout.dev: Channels{% endblock %}
{% block main %}
{% let email_data = channel.email_data.clone().unwrap() %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<h1>Channel Configuration</h1>
</section>
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
>
<div class="mb-3">
<label for="channel-name-input" class="form-label">Channel Name</label>
<input
type="text"
class="form-control"
id="channel-name-input"
name="name"
value="{{ channel.name }}"
>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
class="form-check-input"
{% if channel.enable_by_default %}
checked=""
{% endif %}
type="checkbox"
name="enable_by_default"
value="true"
role="switch"
id="channel-default-enabled-switch"
>
<label class="form-check-label" for="channel-default-enabled-switch">
Enable by default for new projects in this team
</label>
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</section>
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<div class="mb-3">
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
<input
type="text"
class="form-control"
id="channel-recipient-input"
name="recipient"
value="{{ email_data.recipient }}"
aria-describedby="channel-recipient-help"
>
<div id="channel-recipient-help" class="form-text">
{% if email_data.verified %}
Updating this will require verification of the new recipient.
{% else %}
Recipient must be verified before they can receive messages.
{% endif %}
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Send Verification</button>
</div>
</form>
</section>
{% if email_data.recipient != "" && !email_data.verified %}
{% if let BackendConfig::Email(email_data) = channel.backend_config %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<h1>Channel Configuration</h1>
</section>
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
>
<div class="mb-3">
<label for="channel-recipient-verification-code" class="form-label">
Verification Code
</label>
<label for="channel-name-input" class="form-label">Channel Name</label>
<input
type="text"
class="form-control"
name="code"
id="channel-recipient-verification-code"
aria-describedby="channel-recipient-verification-code-help"
id="channel-name-input"
name="name"
value="{{ channel.name }}"
>
<div id="channel-recipient-verification-code-help" class="form-text">
Enter the most recent Shout.dev verification code for this address.
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
type="submit"
form="email-verification-form"
class="btn btn-link align-baseline p-0"
type="submit"
style="font-size: inherit;"
value="Re-send verification email"
class="form-check-input"
{% if channel.enable_by_default %}
checked=""
{% endif %}
type="checkbox"
name="enable_by_default"
value="true"
role="switch"
id="channel-default-enabled-switch"
>
<label class="form-check-label" for="channel-default-enabled-switch">
Enable by default for new projects in this team
</label>
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Verify</button>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</section>
<section class="mb-4">
<form
id="email-verification-form"
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
<input
type="text"
class="form-control"
id="channel-recipient-input"
name="recipient"
value="{{ email_data.recipient }}"
aria-describedby="channel-recipient-help"
>
<div id="channel-recipient-help" class="form-text">
{% if email_data.verified %}
Updating this will require verification of the new recipient.
{% else %}
Recipient must be verified before they can receive messages.
{% endif %}
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Send Verification</button>
</div>
</form>
</section>
{% endif %}
</main>
{% if email_data.recipient != "" && !email_data.verified %}
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
>
<div class="mb-3">
<label for="channel-recipient-verification-code" class="form-label">
Verification Code
</label>
<input
type="text"
class="form-control"
name="code"
id="channel-recipient-verification-code"
aria-describedby="channel-recipient-verification-code-help"
>
<div id="channel-recipient-verification-code-help" class="form-text">
Enter the most recent Shout.dev verification code for this address.
<input
type="submit"
form="email-verification-form"
class="btn btn-link align-baseline p-0"
type="submit"
style="font-size: inherit;"
value="Re-send verification email"
>
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Verify</button>
</div>
</form>
<form
id="email-verification-form"
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
</form>
</section>
{% endif %}
</main>
{% endif %}
{% endblock %}