Compare commits

...

7 commits

28 changed files with 1340 additions and 258 deletions

View file

@ -9,3 +9,8 @@ which to broadcast messages via email and other means of communication.
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.
## Notable Assumptions
- OAuth provider provides accurate "email" field in the userinfo payload.
- OAuth provider does not allow users to change their emails after signup.

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS team_invitations;

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS team_invitations (
id UUID NOT NULL PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
email TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
verification_code TEXT NOT NULL,
UNIQUE (team_id, email)
);
CREATE INDEX ON team_invitations(team_id);
CREATE INDEX ON team_invitations(verification_code);

View file

@ -1,64 +1,48 @@
use std::fmt::{self, Display};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::response::{IntoResponse, Response};
use validator::ValidationErrors;
#[derive(Debug)]
pub struct AuthRedirectInfo {
base_path: String,
}
/// Custom error type that maps to appropriate HTTP responses.
#[derive(Debug)]
pub enum AppError {
InternalServerError(anyhow::Error),
ForbiddenError(String),
NotFoundError(String),
BadRequestError(String),
TooManyRequestsError(String),
AuthRedirect(AuthRedirectInfo),
Forbidden(String),
NotFound(String),
BadRequest(String),
TooManyRequests(String),
}
impl AppError {
pub fn auth_redirect_from_base_path(base_path: String) -> Self {
Self::AuthRedirect(AuthRedirectInfo { base_path })
}
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
// TODO: customize validation errors formatting
Self::BadRequestError(
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
)
Self::BadRequest(serde_json::to_string(&errs).unwrap_or("validation error".to_string()))
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
Self::AuthRedirect(AuthRedirectInfo { base_path }) => {
tracing::debug!("Handling AuthRedirect");
Redirect::to(&format!("{}/auth/login", base_path)).into_response()
}
Self::InternalServerError(err) => {
tracing::error!("Application error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
}
Self::ForbiddenError(client_message) => {
Self::Forbidden(client_message) => {
tracing::info!("Forbidden: {}", client_message);
(StatusCode::FORBIDDEN, client_message).into_response()
}
Self::NotFoundError(client_message) => {
Self::NotFound(client_message) => {
tracing::info!("Not found: {}", client_message);
(StatusCode::NOT_FOUND, client_message).into_response()
}
Self::TooManyRequestsError(client_message) => {
Self::TooManyRequests(client_message) => {
// Debug level so that if this is from a runaway loop, it won't
// overwhelm server logs
tracing::debug!("Too many requests: {}", client_message);
(StatusCode::TOO_MANY_REQUESTS, client_message).into_response()
}
Self::BadRequestError(client_message) => {
Self::BadRequest(client_message) => {
tracing::info!("Bad user input: {}", client_message);
(StatusCode::BAD_REQUEST, client_message).into_response()
}
@ -79,18 +63,17 @@ where
impl Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::AuthRedirect(info) => write!(f, "AuthRedirect: {:?}", info),
AppError::InternalServerError(inner) => inner.fmt(f),
AppError::ForbiddenError(client_message) => {
AppError::Forbidden(client_message) => {
write!(f, "ForbiddenError: {}", client_message)
}
AppError::NotFoundError(client_message) => {
AppError::NotFound(client_message) => {
write!(f, "NotFoundError: {}", client_message)
}
AppError::BadRequestError(client_message) => {
AppError::BadRequest(client_message) => {
write!(f, "BadRequestError: {}", client_message)
}
AppError::TooManyRequestsError(client_message) => {
AppError::TooManyRequests(client_message) => {
write!(f, "TooManyRequestsError: {}", client_message)
}
}

View file

@ -1,11 +1,10 @@
use anyhow::{Context, Result};
use async_session::{Session, SessionStore as _};
use async_session::{Session, SessionStore};
use axum::{
extract::{FromRequestParts, Query, State},
http::request::Parts,
extract::{Query, State},
response::{IntoResponse, Redirect},
routing::get,
RequestPartsExt, Router,
Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use oauth2::{
@ -13,7 +12,6 @@ use oauth2::{
ClientSecret, CsrfToken, RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
};
use serde::{Deserialize, Serialize};
use tracing::{trace_span, Instrument};
use crate::{
app_error::AppError,
@ -24,7 +22,8 @@ use crate::{
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_token";
const SESSION_KEY_AUTH_REFRESH_TOKEN: &str = "oauth_refresh_token";
const SESSION_KEY_AUTH_INFO: &str = "auth";
pub const SESSION_KEY_AUTH_INFO: &str = "auth";
pub const SESSION_KEY_AUTH_REDIRECT: &str = "post_auth_redirect";
/// Creates a new OAuth2 client to be stored in global application state.
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
@ -39,8 +38,11 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
),
)
.set_redirect_uri(
RedirectUrl::new(settings.auth.redirect_url.clone())
.context("failed to create new redirection URL")?,
RedirectUrl::new(format!(
"{}{}/auth/callback",
settings.frontend_host, settings.base_path
))
.context("failed to create new redirection URL")?,
))
}
@ -64,29 +66,33 @@ async fn start_login(
AppSession(maybe_session): AppSession,
jar: CookieJar,
) -> Result<impl IntoResponse, AppError> {
if let Some(session) = maybe_session {
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
tracing::debug!("already logged in, redirecting...");
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
}
let mut session = if let Some(value) = maybe_session {
value
} else {
Session::new()
};
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
tracing::debug!("already logged in, redirecting...");
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
}
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
let csrf_token = CsrfToken::new_random();
let (auth_url, _csrf_token) = state
.oauth_client
.authorize_url(|| csrf_token.clone())
.url();
let mut session = Session::new();
session.insert(SESSION_KEY_AUTH_CSRF_TOKEN, &csrf_token)?;
let cookie_value = session_store
.store_session(session)
.await?
.ok_or(anyhow::anyhow!("cookie value from store_session() is None"))?;
let jar = jar.add(
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
.same_site(SameSite::Lax)
.http_only(true)
.path("/"),
);
let (auth_url, _csrf_token) = state.oauth_client.authorize_url(|| csrf_token).url();
let jar = if let Some(cookie_value) = session_store.store_session(session).await? {
tracing::debug!("adding session cookie to jar");
jar.add(
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
.same_site(SameSite::Lax)
.http_only(true)
.path("/"),
)
} else {
tracing::debug!("inferred that session cookie already in jar");
jar
};
Ok((jar, Redirect::to(auth_url.as_ref())).into_response())
}
@ -150,18 +156,21 @@ async fn callback(
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
AppSession(session): AppSession,
) -> Result<impl IntoResponse, AppError> {
let mut session = if let Some(session) = session {
session
} else {
return Err(AppError::auth_redirect_from_base_path(base_path));
};
let mut session = session.ok_or_else(|| {
tracing::debug!("unable to load session");
AppError::Forbidden(
"our apologies: authentication session expired or lost, please try again".to_owned(),
)
})?;
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
tracing::debug!("oauth csrf token not found on session");
AppError::auth_redirect_from_base_path(base_path.clone())
AppError::Forbidden(
"our apologies: authentication session expired or lost, please try again".to_owned(),
)
})?;
if session_csrf_token != query.state {
tracing::debug!("oauth csrf tokens did not match");
return Err(AppError::ForbiddenError(
return Err(AppError::Forbidden(
"OAuth CSRF tokens do not match.".to_string(),
));
}
@ -180,6 +189,12 @@ async fn callback(
.json()
.await?;
tracing::debug!("updating session");
let redirect_target: Option<String> = session.get(SESSION_KEY_AUTH_REDIRECT);
// Remove this since we don't need or want it sticking around, for both UX
// and security hygiene reasons
session.remove(SESSION_KEY_AUTH_REDIRECT);
session.insert(SESSION_KEY_AUTH_INFO, &auth_info)?;
session.insert(SESSION_KEY_AUTH_REFRESH_TOKEN, response.refresh_token())?;
if state.session_store.store_session(session).await?.is_some() {
@ -189,7 +204,9 @@ async fn callback(
.into());
}
tracing::debug!("successfully authenticated");
Ok(Redirect::to(&format!("{}/", base_path)))
Ok(Redirect::to(
&redirect_target.unwrap_or(format!("{}/", base_path)),
))
}
/// Data stored in the visitor's session upon successful authentication.
@ -198,29 +215,3 @@ pub struct AuthInfo {
pub sub: String,
pub email: String,
}
impl FromRequestParts<AppState> for AuthInfo {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
async move {
let session = parts
.extract_with_state::<AppSession, AppState>(state)
.await?
.0
.ok_or(AppError::auth_redirect_from_base_path(
state.settings.base_path.clone(),
))?;
let user = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).ok_or(
AppError::auth_redirect_from_base_path(state.settings.base_path.clone()),
)?;
Ok(user)
}
// The Span.enter() guard pattern doesn't play nicely with async
.instrument(trace_span!("AuthInfo from_request_parts()"))
.await
}
}

View file

@ -9,7 +9,6 @@ use axum::{
use axum_extra::extract::Form;
use diesel::prelude::*;
use rand::Rng as _;
use regex::Regex;
use serde::Deserialize;
use uuid::Uuid;
@ -18,7 +17,7 @@ use crate::{
app_state::{AppState, DbConn},
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
csrf::generate_csrf_token,
email::{MailSender as _, Mailer},
email::{is_permissible_email, MailSender as _, Mailer},
guards,
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
schema::channels,
@ -40,7 +39,7 @@ fn get_channel_by_params<'a>(
.filter(Channel::with_team(team_id))
.first(conn)
{
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError(
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFound(
"Channel with that team and ID not found.".to_string(),
)),
diesel::QueryResult::Err(err) => Err(err.into()),
@ -153,7 +152,7 @@ async fn post_new_channel(
.await
.unwrap()?,
_ => {
return Err(AppError::BadRequestError(
return Err(AppError::BadRequest(
"Channel type not recognized.".to_string(),
));
}
@ -189,7 +188,7 @@ async fn channel_page(
.unwrap()?
{
None => {
return Err(AppError::NotFoundError(
return Err(AppError::NotFound(
"Channel with that team and ID not found".to_string(),
));
}
@ -272,7 +271,7 @@ async fn update_channel(
.context("Failed to load Channel while updating.")?
};
if updated_rows != 1 {
return Err(AppError::NotFoundError(
return Err(AppError::NotFound(
"Channel with that team and ID not found".to_string(),
));
}
@ -308,7 +307,7 @@ async fn update_channel_email_recipient(
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
if !is_permissible_email(&form_body.recipient) {
return Err(AppError::BadRequestError(
return Err(AppError::BadRequest(
"Unable to validate email address format.".to_string(),
));
}
@ -374,15 +373,6 @@ async fn update_channel_email_recipient(
)))
}
/// 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,
@ -400,7 +390,7 @@ async fn verify_email(
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
if form_body.code.len() != VERIFICATION_CODE_LEN {
return Err(AppError::BadRequestError(format!(
return Err(AppError::BadRequest(format!(
"Verification code must be {} characters long.",
VERIFICATION_CODE_LEN
)));
@ -414,15 +404,13 @@ async fn verify_email(
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(
return Err(AppError::BadRequest(
"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(),
));
return Err(AppError::BadRequest("Verification expired.".to_string()));
}
let new_config = if config.verification_code == verification_code {
EmailBackendConfig {

View file

@ -2,12 +2,22 @@ use anyhow::{Context, Result};
use axum::extract::FromRef;
use futures::Future;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use regex::Regex;
use serde::{Serialize, Serializer};
use crate::app_state::AppState;
const POSTMARK_EMAIL_BATCH_URL: &str = "https://api.postmarkapp.com/email/batch";
/// 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.
pub 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(Clone, Serialize)]
pub struct Message {
#[serde(rename = "From")]

View file

@ -4,7 +4,10 @@ use diesel::prelude::*;
use uuid::Uuid;
use crate::{
app_error::AppError, csrf::validate_csrf_token, team_memberships::TeamMembership, teams::Team,
app_error::AppError,
csrf::validate_csrf_token,
team_memberships::{PopulatedTeamMembership, TeamMembership},
teams::Team,
users::User,
};
@ -17,27 +20,23 @@ pub async fn require_team_membership(
team_id: &Uuid,
db_conn: &Connection,
) -> Result<Team, AppError> {
let maybe_team = {
let current_user_id = current_user.id;
let team_id = *team_id;
db_conn
.interact::<_, Result<Option<(Team, _)>>>(move |conn| {
TeamMembership::all()
.filter(TeamMembership::with_user_id(&current_user_id))
.filter(TeamMembership::with_team_id(&team_id))
.first(conn)
.optional()
.map_err(Into::into)
})
.await
.unwrap()?
};
match maybe_team {
Some((team, _)) => Ok(team),
None => Err(AppError::ForbiddenError(
"not a member of requested team".to_string(),
)),
}
let current_user_id = current_user.id;
let team_id = *team_id;
db_conn
.interact::<_, Result<Option<PopulatedTeamMembership>>>(move |conn| {
PopulatedTeamMembership::all()
.filter(TeamMembership::with_user_id(&current_user_id))
.filter(TeamMembership::with_team_id(&team_id))
.first(conn)
.optional()
.map_err(Into::into)
})
.await
.unwrap()?
.map(|team_membership| team_membership.team)
.ok_or(AppError::Forbidden(
"not a member of requested team".to_owned(),
))
}
/// Returns a ForbiddenError if the CSRF token parameters do not match an entry
@ -50,6 +49,6 @@ pub async fn require_valid_csrf_token(
if validate_csrf_token(db_conn, csrf_token, Some(current_user.id)).await? {
Ok(())
} else {
Err(AppError::ForbiddenError("invalid CSRF token".to_string()))
Err(AppError::Forbidden("invalid CSRF token".to_string()))
}
}

View file

@ -32,6 +32,7 @@ mod router;
mod schema;
mod sessions;
mod settings;
mod team_invitations;
mod team_memberships;
mod teams;
mod teams_router;

View file

@ -7,6 +7,7 @@ use crate::app_state::AppState;
pub const NAVBAR_ITEM_TEAMS: &str = "teams";
pub const NAVBAR_ITEM_PROJECTS: &str = "projects";
pub const NAVBAR_ITEM_CHANNELS: &str = "channels";
pub const NAVBAR_ITEM_TEAM_MEMBERS: &str = "team-members";
#[derive(Clone, Debug)]
pub struct BreadcrumbTrail {
@ -202,6 +203,11 @@ impl Default for NavbarBuilder {
"Channels",
"/en/teams/{team_id}/channels",
)
.push_item(
NAVBAR_ITEM_TEAM_MEMBERS,
"Team Members",
"/en/teams/{team_id}/members",
)
}
}

View file

@ -124,7 +124,7 @@ async fn project_page(
.filter(Project::with_team(&team_id))
.first(conn)
{
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound(
"Project with that team and ID not found.".to_string(),
)),
other => other
@ -213,7 +213,7 @@ async fn update_enabled_channels(
.filter(Project::with_team(&team_id))
.first(conn)
{
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound(
"Project with that team and ID not found.".to_string(),
)),
other => other

View file

@ -81,6 +81,16 @@ diesel::table! {
}
}
diesel::table! {
team_invitations (id) {
id -> Uuid,
team_id -> Uuid,
email -> Text,
created_by -> Uuid,
verification_code -> Text,
}
}
diesel::table! {
team_memberships (team_id, user_id) {
team_id -> Uuid,
@ -114,6 +124,8 @@ diesel::joinable!(governors -> teams (team_id));
diesel::joinable!(messages -> channels (channel_id));
diesel::joinable!(messages -> projects (project_id));
diesel::joinable!(projects -> teams (team_id));
diesel::joinable!(team_invitations -> teams (team_id));
diesel::joinable!(team_invitations -> users (created_by));
diesel::joinable!(team_memberships -> teams (team_id));
diesel::joinable!(team_memberships -> users (user_id));
@ -127,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!(
governors,
messages,
projects,
team_invitations,
team_memberships,
teams,
users,

View file

@ -8,20 +8,29 @@ use crate::app_state::AppState;
#[derive(Clone, Debug, Deserialize)]
pub struct Settings {
/// Prefix under which to nest all routes. If specified, include leading
/// slash but no trailing slash, for example "/app". For default behavior,
/// leave as empty string.
#[serde(default)]
pub base_path: String,
/// postgresql:// URL.
pub database_url: String,
/// When set to 1, embedded Diesel migrations will be run on startup.
pub run_database_migrations: Option<u8>,
/// Address for server to bind to
#[serde(default = "default_host")]
pub host: String,
/// Port for server to bind to
#[serde(default = "default_port")]
pub port: u16,
/// Host visible to end users, for example "https://shout.dev"
pub frontend_host: String,
pub auth: AuthSettings,
pub email: EmailSettings,
@ -39,7 +48,6 @@ fn default_host() -> String {
pub struct AuthSettings {
pub client_id: String,
pub client_secret: String,
pub redirect_url: String,
pub auth_url: String,
pub token_url: String,
pub userinfo_url: String,

202
src/team_invitations.rs Normal file
View file

@ -0,0 +1,202 @@
use anyhow::{bail, Context as _, Result};
use diesel::{
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
upsert::excluded,
};
use tracing::Instrument;
use uuid::Uuid;
use crate::{
email::{is_permissible_email, MailSender as _, Mailer, Message},
schema::{team_invitations, team_memberships, teams, users},
settings::Settings,
team_memberships::TeamMembership,
teams::Team,
users::User,
};
#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
#[diesel(table_name = team_invitations)]
#[diesel(check_for_backend(Pg))]
pub struct TeamInvitation {
pub id: Uuid,
pub team_id: Uuid,
pub email: String,
pub created_by: Uuid,
pub verification_code: String,
}
impl TeamInvitation {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<TeamInvitation, Pg> = TeamInvitation::as_select();
team_invitations::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_id(id: &Uuid) -> _ {
team_invitations::id.eq(id)
}
#[auto_type(no_type_alias)]
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
team_invitations::team_id.eq(id)
}
#[auto_type(no_type_alias)]
pub fn with_verification_code<'a>(code: &'a str) -> _ {
team_invitations::verification_code.eq(code)
}
#[auto_type(no_type_alias)]
pub fn with_email<'a>(email: &'a str) -> _ {
team_invitations::email.eq(email)
}
/// Reload and join with Team and User tables.
pub fn repopulate(&self, db_conn: &mut PgConnection) -> Result<PopulatedTeamInvitation> {
PopulatedTeamInvitation::all()
.filter(TeamInvitation::with_id(&self.id))
.first(db_conn)
.context("failed to load populated invitation")
}
pub fn accept_for_user(&self, user_id: &Uuid, db_conn: &mut PgConnection) -> Result<()> {
let _guard = tracing::debug_span!(
"TeamInvitation::accept_for_user",
user_id = user_id.hyphenated().to_string(),
team_id = self.team_id.hyphenated().to_string()
)
.entered();
let user_id = *user_id;
let invitation_id = self.id;
let team_id = self.team_id;
db_conn
.transaction::<(), anyhow::Error, _>(move |conn| {
let n_inserted = diesel::insert_into(team_memberships::table)
.values(TeamMembership { user_id, team_id })
.on_conflict((team_memberships::team_id, team_memberships::user_id))
.do_nothing()
.execute(conn)
.context("failed to create team membership")?;
if n_inserted == 0 {
tracing::debug!("nothing inserted; team membership must already exist");
} else {
tracing::debug!("inserted new team membership");
}
let n_deleted = diesel::delete(
team_invitations::table.filter(TeamInvitation::with_id(&invitation_id)),
)
.execute(conn)
.context("failed to delete invitation")?;
if n_deleted != 1 {
tracing::error!("expected to be deleting 1 invitation, but deleting {} instead; rolling back transaction", n_deleted);
} else {
tracing::debug!("deleted invitation");
}
Ok(())
})
.context("transaction failed while accepting invitation")
.map(|_| ())
}
}
pub struct InvitationBuilder<'a> {
pub team_id: &'a Uuid,
pub email: &'a str,
pub created_by: &'a Uuid,
}
impl InvitationBuilder<'_> {
/// Generate a team invitation and store it in the database. If a
/// conflicting invitation already exists, it will be updated with a new
/// creator and verification code, and the result will be returned.
pub fn insert(self, db_conn: &mut PgConnection) -> Result<TeamInvitation> {
if !is_permissible_email(self.email) {
bail!("email address is expected to conform to standard format");
}
diesel::insert_into(team_invitations::table)
.values(TeamInvitation {
id: Uuid::now_v7(),
team_id: *self.team_id,
email: self.email.to_lowercase(),
created_by: *self.created_by,
verification_code: format!("tminv-{}", Uuid::new_v4().hyphenated()),
})
.on_conflict((team_invitations::team_id, team_invitations::email))
.do_update()
.set((
team_invitations::created_by.eq(excluded(team_invitations::created_by)),
team_invitations::verification_code
.eq(excluded(team_invitations::verification_code)),
))
.get_result(db_conn)
.context("failed to insert team invitation")
}
}
/// Inner join of TeamInvitation with its creator (User) and Team.
#[derive(Clone, Debug, Selectable, Queryable)]
pub struct PopulatedTeamInvitation {
#[diesel(embed)]
pub creator: User,
#[diesel(embed)]
pub invitation: TeamInvitation,
#[diesel(embed)]
pub team: Team,
}
impl PopulatedTeamInvitation {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let as_select: AsSelect<Self, Pg> = Self::as_select();
team_invitations::table
.inner_join(teams::table)
.inner_join(users::table.on(users::id.eq(team_invitations::created_by)))
.select(as_select)
}
pub async fn send_by_email(&self, mailer: &Mailer, settings: &Settings) -> Result<()> {
async {
if !is_permissible_email(&self.invitation.email) {
bail!("email address is expected to conform to standard format");
}
let invite_url = format!(
"{}{}/en/teams/{}/accept-invitation?verification_code={}",
settings.frontend_host,
settings.base_path,
self.team.id.simple(),
self.invitation.verification_code
);
let message = format!(
"{} has invited you to the Shout.dev team, {}.\n\nAccept invitation: {}",
self.creator.email,
serde_json::to_string(&self.team.name)?,
invite_url,
);
tracing::debug!("mailing team invitation with url: {}", invite_url);
mailer
.send_batch(vec![Message {
from: settings.email.verification_from.clone(),
to: self
.invitation
.email
.parse()
.context("failed to parse recipient as mailbox")?,
subject: "Shout.dev: You're invited!".to_owned(),
text_body: message,
}])
.await
.remove(0)?;
tracing::debug!("invitation mailed");
Ok(())
}
.instrument(tracing::debug_span!(
"PopulatedTeamInvitation::send_by_email",
team_invitation = self.invitation.id.hyphenated().to_string()
))
.await
}
}

View file

@ -21,15 +21,6 @@ pub struct TeamMembership {
}
impl TeamMembership {
#[diesel::dsl::auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<(Team, User), Pg> = <(Team, User)>::as_select();
team_memberships::table
.inner_join(teams::table)
.inner_join(users::table)
.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
team_memberships::team_id.eq(id)
@ -40,3 +31,22 @@ impl TeamMembership {
team_memberships::user_id.eq(id)
}
}
#[derive(Clone, Debug, Queryable, Selectable)]
pub struct PopulatedTeamMembership {
#[diesel(embed)]
pub team: Team,
#[diesel(embed)]
pub user: User,
}
impl PopulatedTeamMembership {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
team_memberships::table
.inner_join(teams::table)
.inner_join(users::table)
.select(select)
}
}

View file

@ -7,7 +7,10 @@ use uuid::Uuid;
use crate::{
api_keys::ApiKey,
schema::{api_keys, teams},
schema::{api_keys, team_invitations, team_memberships, teams, users},
team_invitations::{PopulatedTeamInvitation, TeamInvitation},
team_memberships::TeamMembership,
users::User,
};
/// Teams are the fundamental organizing unit for billing and help to
@ -28,10 +31,42 @@ impl Team {
teams::table.select(select)
}
#[auto_type(no_type_alias)]
pub fn with_id(id: &Uuid) -> _ {
teams::id.eq(id)
}
#[auto_type(no_type_alias)]
pub fn api_keys(&self) -> _ {
let all: diesel::dsl::Select<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
let filter: Eq<api_keys::team_id, &Uuid> = ApiKey::with_team(&self.id);
all.filter(filter)
}
#[auto_type(no_type_alias)]
pub fn invitations(&self) -> _ {
#[allow(clippy::type_complexity)]
let all: diesel::dsl::Select<
diesel::dsl::InnerJoin<
diesel::dsl::InnerJoin<team_invitations::table, teams::table>,
diesel::dsl::On<
users::table,
diesel::dsl::Eq<users::id, team_invitations::created_by>,
>,
>,
AsSelect<PopulatedTeamInvitation, Pg>,
> = PopulatedTeamInvitation::all();
let filter: Eq<team_invitations::team_id, &Uuid> = TeamInvitation::with_team_id(&self.id);
all.filter(filter)
}
#[auto_type(no_type_alias)]
pub fn members(&self) -> _ {
let select: AsSelect<User, Pg> = User::as_select();
let filter: Eq<team_memberships::team_id, &Uuid> = TeamMembership::with_team_id(&self.id);
team_memberships::table
.inner_join(users::table)
.filter(filter)
.select(select)
}
}

View file

@ -1,4 +1,4 @@
use anyhow::Context as _;
use anyhow::{Context as _, Result};
use askama::Template;
use axum::{
extract::{Path, State},
@ -16,24 +16,47 @@ use crate::{
app_error::AppError,
app_state::{AppState, DbConn},
csrf::generate_csrf_token,
email::{is_permissible_email, Mailer},
guards,
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS},
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS, NAVBAR_ITEM_TEAM_MEMBERS},
projects::{Project, DEFAULT_PROJECT_NAME},
schema::{api_keys, team_memberships, teams},
schema::{api_keys, team_invitations, team_memberships, teams, users},
settings::Settings,
team_invitations::{InvitationBuilder, PopulatedTeamInvitation, TeamInvitation},
team_memberships::TeamMembership,
teams::Team,
users::CurrentUser,
users::{CurrentUser, User},
};
pub fn new_router() -> Router<AppState> {
Router::new()
.route("/teams", get(teams_page))
.route("/new-team", get(new_team_page))
.route("/new-team", post(post_new_team))
.route("/teams/{team_id}", get(team_page))
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
.route("/teams/{team_id}/remove-api-key", post(remove_api_key))
.route("/new-team", get(new_team_page))
.route("/new-team", post(post_new_team))
.route("/teams/{team_id}/members", get(team_members_page))
.route(
"/teams/{team_id}/invite-team-member",
post(invite_team_member),
)
.route(
"/teams/{team_id}/accept-invitation",
get(accept_invitation_page),
)
.route(
"/teams/{team_id}/accept-invitation",
post(post_accept_invitation),
)
.route(
"/teams/{team_id}/remove-team-member",
post(remove_team_member),
)
.route(
"/teams/{team_id}/remove-team-invitation",
post(remove_team_invitation),
)
}
async fn teams_page(
@ -71,87 +94,6 @@ async fn teams_page(
))
}
async fn team_page(
State(Settings { base_path, .. }): State<Settings>,
Path(team_id): Path<Uuid>,
) -> impl IntoResponse {
Redirect::to(&format!("{}/en/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, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
ApiKey::generate_for_team(&db_conn, team.id).await?;
Ok(Redirect::to(&format!(
"{}/en/teams/{}/projects",
base_path,
team.id.simple()
))
.into_response())
}
#[derive(Deserialize)]
struct RemoveApiKeyForm {
csrf_token: String,
key_id: Uuid,
}
async fn remove_api_key(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveApiKeyForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let n_deleted = {
let team_id = team.id;
db_conn
.interact::<_, Result<usize, AppError>>(move |conn| {
diesel::delete(
api_keys::table
.filter(ApiKey::with_team(&team_id))
.filter(ApiKey::with_id(&form.key_id)),
)
.execute(conn)
.context("failed to delete API key from database")
.map_err(Into::into)
})
.await
.unwrap()?
};
assert!(
n_deleted < 2,
"there should never be more than 1 API key with the same ID"
);
if n_deleted == 0 {
Err(AppError::NotFoundError(
"no API key with that ID and team found".to_owned(),
))
} else {
Ok(Redirect::to(&format!(
"{}/en/teams/{}/projects",
base_path,
team.id.simple()
))
.into_response())
}
}
async fn new_team_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
@ -223,3 +165,395 @@ async fn post_new_team(
ApiKey::generate_for_team(&db_conn, team_id).await?;
Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response())
}
async fn team_page(
State(Settings { base_path, .. }): State<Settings>,
Path(team_id): Path<Uuid>,
) -> impl IntoResponse {
Redirect::to(&format!("{}/en/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, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
ApiKey::generate_for_team(&db_conn, team.id).await?;
Ok(Redirect::to(&format!(
"{}/en/teams/{}/projects",
base_path,
team.id.simple()
))
.into_response())
}
#[derive(Deserialize)]
struct RemoveApiKeyForm {
csrf_token: String,
key_id: Uuid,
}
async fn remove_api_key(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveApiKeyForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let n_deleted = {
let team_id = team.id;
db_conn
.interact::<_, Result<usize, AppError>>(move |conn| {
diesel::delete(
api_keys::table
.filter(ApiKey::with_team(&team_id))
.filter(ApiKey::with_id(&form.key_id)),
)
.execute(conn)
.context("failed to delete API key from database")
.map_err(Into::into)
})
.await
.unwrap()?
};
assert!(
n_deleted < 2,
"there should never be more than 1 API key with the same ID"
);
if n_deleted == 0 {
Err(AppError::NotFound(
"no API key with that ID and team found".to_owned(),
))
} else {
Ok(Redirect::to(&format!(
"{}/en/teams/{}/projects",
base_path,
team.id.simple()
))
.into_response())
}
}
async fn team_members_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let (team_members, invitations) = {
let team = team.clone();
db_conn
.interact::<_, Result<(Vec<User>, Vec<PopulatedTeamInvitation>)>>(move |conn| {
let team_members = team.members().order_by(users::email.asc()).load(conn)?;
let invitations = team.invitations().order_by(users::email.asc()).load(conn)?;
Ok((team_members, invitations))
})
.await
.unwrap()?
};
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
#[derive(Template)]
#[template(path = "team-members.html")]
struct ResponseTemplate {
base_path: String,
breadcrumbs: BreadcrumbTrail,
csrf_token: String,
current_user: User,
invitations: Vec<PopulatedTeamInvitation>,
team_members: Vec<User>,
team_name: String,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
.with_i18n_slug("en")
.push_slug("Teams", "teams")
.push_slug(&team.name, &team.id.simple().to_string())
.push_slug("Members", "members"),
base_path,
csrf_token,
current_user,
invitations,
navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_TEAM_MEMBERS)
.build(),
team_members,
team_name: team.name,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
struct InviteTeamMemberForm {
csrf_token: String,
email: String,
}
async fn invite_team_member(
State(settings): State<Settings>,
mailer: State<Mailer>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<InviteTeamMemberForm>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
if !is_permissible_email(&form.email) {
return Err(AppError::BadRequest(
"Unable to validate email address format.".to_owned(),
));
}
let invitation = {
let team_id = team.id;
db_conn
.interact::<_, Result<PopulatedTeamInvitation, AppError>>(move |conn| {
InvitationBuilder {
team_id: &team_id,
email: &form.email,
created_by: &current_user.id,
}
.insert(conn)?
.repopulate(conn)
.map_err(Into::into)
})
.await
.unwrap()?
};
invitation.send_by_email(&mailer, &settings).await?;
Ok(Redirect::to(&format!(
"{}/en/teams/{}/members",
settings.base_path, team_id
))
.into_response())
}
#[derive(Deserialize)]
struct AcceptInvitationPageForm {
verification_code: String,
}
async fn accept_invitation_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<AcceptInvitationPageForm>,
) -> Result<impl IntoResponse, AppError> {
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
let maybe_invitation = db_conn
.interact::<_, Result<Option<PopulatedTeamInvitation>>>(move |conn| {
PopulatedTeamInvitation::all()
.filter(TeamInvitation::with_verification_code(
&form.verification_code,
))
.first(conn)
.optional()
.context("failed to load populated team invitation")
})
.await
.unwrap()?;
if let Some(invitation) = maybe_invitation {
if invitation.invitation.email != current_user.email.to_lowercase() {
Err(AppError::Forbidden(format!(
"please use the account with email {} to accept this invitation",
invitation.invitation.email
)))
} else if invitation.team.id != team_id {
Err(AppError::BadRequest("team ids do not match".to_owned()))
} else {
#[derive(Template)]
#[template(path = "accept-team-invitation.html")]
struct ResponseTemplate {
base_path: String,
csrf_token: String,
invitation: PopulatedTeamInvitation,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
base_path,
csrf_token,
invitation,
navbar: navbar_template
.with_param("team_id", &team_id.simple().to_string())
.with_active_item(NAVBAR_ITEM_TEAM_MEMBERS)
.build(),
}
.render()?,
)
.into_response())
}
} else {
Err(AppError::NotFound(
"unable to find team invitation".to_owned(),
))
}
}
#[derive(Deserialize)]
struct PostAcceptInvitationForm {
csrf_token: String,
verification_code: String,
}
async fn post_accept_invitation(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostAcceptInvitationForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let maybe_invitation = {
let current_email = current_user.email.clone();
db_conn
.interact::<_, Result<Option<TeamInvitation>>>(move |conn| {
TeamInvitation::all()
.filter(TeamInvitation::with_email(&current_email))
.filter(TeamInvitation::with_verification_code(
&form.verification_code,
))
.filter(TeamInvitation::with_team_id(&team_id))
.first(conn)
.optional()
.context("failed to load team invitation")
})
.await
.unwrap()?
};
let invitation = maybe_invitation.ok_or(AppError::NotFound(
"invitation for this team, email address, and verification code not found".to_owned(),
))?;
db_conn
.interact::<_, Result<()>>(move |conn| invitation.accept_for_user(&current_user.id, conn))
.await
.unwrap()?;
Ok(Redirect::to(&format!("{}/en/teams/{}", base_path, team_id)).into_response())
}
#[derive(Deserialize)]
struct RemoveTeamMemberForm {
csrf_token: String,
user_id: Uuid,
}
async fn remove_team_member(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveTeamMemberForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
tracing::debug!(
"{} requesting that {} be removed from team {}",
current_user.id,
form.user_id,
team_id
);
let n_deleted = {
let team_id = team.id;
db_conn
.interact::<_, Result<usize, AppError>>(move |conn| {
diesel::delete(
team_memberships::table
.filter(TeamMembership::with_team_id(&team_id))
.filter(TeamMembership::with_user_id(&form.user_id)),
)
.execute(conn)
.context("failed to delete team membership from database")
.map_err(Into::into)
})
.await
.unwrap()?
};
if n_deleted == 0 {
Err(AppError::NotFound("no team membership found".to_owned()))
} else {
Ok(Redirect::to(&format!(
"{}/en/teams/{}/members",
base_path,
team.id.simple()
))
.into_response())
}
}
#[derive(Deserialize)]
struct RemoveTeamInvitationForm {
csrf_token: String,
invitation_id: Uuid,
}
async fn remove_team_invitation(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveTeamInvitationForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
tracing::debug!(
"{} requesting that invitation {} be removed from team {}",
current_user.id,
form.invitation_id,
team_id
);
let n_deleted = db_conn
.interact::<_, Result<usize, AppError>>(move |conn| {
diesel::delete(
team_invitations::table.filter(TeamInvitation::with_id(&form.invitation_id)),
)
.execute(conn)
.context("failed to delete invitation from database")
.map_err(Into::into)
})
.await
.unwrap()?;
if n_deleted == 0 {
Err(AppError::NotFound("no invitation found".to_owned()))
} else {
Ok(Redirect::to(&format!(
"{}/en/teams/{}/members",
base_path,
team.id.simple()
))
.into_response())
}
}

View file

@ -1,5 +1,15 @@
use anyhow::Context;
use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt};
use async_session::{Session, SessionStore as _};
use axum::{
extract::{FromRequestParts, OriginalUri},
http::{request::Parts, Method},
response::{IntoResponse, Redirect, Response},
RequestPartsExt,
};
use axum_extra::extract::{
cookie::{Cookie, SameSite},
CookieJar,
};
use diesel::{
associations::Identifiable,
deserialize::Queryable,
@ -13,8 +23,9 @@ use uuid::Uuid;
use crate::{
app_error::AppError,
app_state::AppState,
auth::AuthInfo,
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
schema::{team_memberships, teams, users},
sessions::AppSession,
team_memberships::TeamMembership,
teams::Team,
};
@ -57,20 +68,54 @@ impl<S> FromRequestParts<S> for CurrentUser
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
type Rejection = CurrentUserRejection;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state: AppState = state.clone().into();
let auth_info = parts
.extract_with_state::<AuthInfo, AppState>(&state)
.await
.map_err(|_| {
AppError::auth_redirect_from_base_path(state.settings.base_path.clone())
})?;
let current_user = state
.db_pool
.get()
.await?
let app_state: AppState = state.clone().into();
let mut session =
if let AppSession(Some(value)) = parts.extract_with_state(&app_state).await? {
value
} else {
Session::new()
};
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
value
} else {
let jar: CookieJar = parts.extract().await?;
let method: Method = parts.extract().await?;
let jar = if method == Method::GET {
let OriginalUri(uri) = parts.extract().await?;
session.insert(
SESSION_KEY_AUTH_REDIRECT,
uri.path_and_query()
.map(|value| value.to_string())
.unwrap_or(format!("{}/", app_state.settings.base_path)),
)?;
if let Some(cookie_value) = app_state.session_store.store_session(session).await? {
tracing::debug!("adding session cookie to jar");
jar.add(
Cookie::build((app_state.settings.auth.cookie_name.clone(), cookie_value))
.same_site(SameSite::Lax)
.http_only(true)
.path("/"),
)
} else {
tracing::debug!("inferred that session cookie already in jar");
jar
}
} else {
// If request method is not GET then do not attempt to infer the
// redirect target, as there may be no GET handler defined for
// it.
jar
};
return Err(Self::Rejection::SetCookiesAndRedirect(
jar,
format!("{}/auth/login", app_state.settings.base_path),
));
};
let db_conn = app_state.db_pool.get().await?;
let current_user = db_conn
.interact(move |conn| {
let maybe_current_user = User::all()
.filter(User::with_uid(&auth_info.sub))
@ -112,3 +157,29 @@ where
Ok(CurrentUser(current_user))
}
}
pub enum CurrentUserRejection {
AppError(AppError),
SetCookiesAndRedirect(CookieJar, String),
}
// Easily convert semi-arbitrary errors to InternalServerError
impl<E> From<E> for CurrentUserRejection
where
E: Into<AppError>,
{
fn from(err: E) -> Self {
Self::AppError(err.into())
}
}
impl IntoResponse for CurrentUserRejection {
fn into_response(self) -> Response {
match self {
Self::AppError(err) => err.into_response(),
Self::SetCookiesAndRedirect(jar, redirect_to) => {
(jar, Redirect::to(&redirect_to)).into_response()
}
}
}
}

View file

@ -67,9 +67,8 @@ async fn say_get(
query.validate().map_err(AppError::from_validation_errors)?;
let api_key = {
let query_key = try_parse_as_uuid(&query.key).or(Err(AppError::ForbiddenError(
"key not accepted".to_string(),
)))?;
let query_key = try_parse_as_uuid(&query.key)
.or(Err(AppError::Forbidden("key not accepted".to_string())))?;
db_conn
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
update(api_keys::table.filter(ApiKey::with_id(&query_key)))
@ -78,7 +77,7 @@ async fn say_get(
.get_result(conn)
.optional()
.context("failed to get API key")?
.ok_or(AppError::ForbiddenError("key not accepted.".to_string()))
.ok_or(AppError::Forbidden("key not accepted.".to_string()))
})
.await
.unwrap()?
@ -146,7 +145,7 @@ async fn say_get(
.unwrap()?
.is_none()
{
return Err(AppError::TooManyRequestsError(
return Err(AppError::TooManyRequests(
"team rate limit exceeded".to_string(),
));
}

93
static/geist/OFL.txt Normal file
View file

@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

41
static/logo.svg Normal file
View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Vectornator (http://vectornator.io/) -->
<svg height="1024.0px" stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="0 0 1024 1024" width="1024.0px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<image height="32" id="Image" width="32" xlink:href="data:image/png;base64,
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAACCgAwAEAAAAAQAAACAAAAAAX7wP8AAAAAlwSFlzAAALEwAACxMBAJqcGAAAABxpRE9UAAAAAgAAAAAAAAAQAAAAKAAAABAAAAAQAAAA/SNwdToAAADJSURBVFgJzJJRDsMgDEO5/2/vsGvSpuqTQgiGThMaUmVip3aytdRaC085PnXHQ57hHb4jNMv42QCX0X2yEMVdL5WiGla1J/+7v281RPXZAEqXmhQXPsq4fayn/tOGyRBx+78Y4NVSb5rZLm6NB/qohm+wKRZ+bkKyISJHr8yQYhgIQ4/+/W0DWKg/fgh/p8dz3b0jwtZej4bUhr6POzp1iik5GCIzhDOMXmiRb+qmGATTMzKEN6TXkOO57t4RYghliGaIJxx1hicAAAD///tXxmIAAAClSURBVM2SUQ6DMAxDe//f3WHXZPKkJ5mEpu1HgUjIrWMnFtDa53vwHJOFPqLb1aOi7nT3C4YRusfP7hNPuSadE2FvJPaqgfSE+OC4X+Il2QnRGwgv9HnwzqVzIjrLpaPcAyd0vqePmpaIhQDV8u0BRsu3BphZfkuA0ecjaKkrm+F/YKBwxoe+1JbNEGCLdsvQleCPB9DnfCrEf/crAijEnUHYJ/wBtf7/1NmEySMAAAAASUVORK5CYII="/>
</defs>
<g id="Layer-1">
<path d="M0 128C0 57.3076 57.3076 0 128 0L896 0C966.692 0 1024 57.3076 1024 128L1024 896C1024 966.692 966.692 1024 896 1024L128 1024C57.3076 1024 0 966.692 0 896L0 128Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
</g>
<g id="Layer-3">
<path d="M96 512L384 512L384 576L96 576L96 512Z" fill="#ffffff" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M416 256L416 832L704 544L416 256Z" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="bevel" stroke-width="64"/>
<path d="M704 256L768 256L768 832L704 832L704 256Z" fill="#ffffff" fill-rule="nonzero" opacity="1" stroke="none"/>
</g>
<g id="Layer-4">
<path d="M352 160L480 160L480 256L352 256L352 160Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M352 832L480 832L480 928L352 928L352 832Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
</g>
<g id="Layer-5">
<g opacity="1">
<path d="M520.055 256L608 85.3258" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
<path d="M607.131 156.875L608 85.3258L550.239 127.56" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
</g>
<g opacity="1">
<path d="M584.055 320L672 149.326" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
<path d="M671.131 220.875L672 149.326L614.239 191.56" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
</g>
</g>
<g id="Layer-2" visibility="hidden">
<g transform="matrix(32 0 0 32 0 0)">
<clipPath id="cp">
<path d="M0 0L32 0L32 32L0 32Z"/>
</clipPath>
<g clip-path="url(#cp)">
<use opacity="0.490461" xlink:href="#Image"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

13
static/main.css Normal file
View file

@ -0,0 +1,13 @@
:root {
--bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji";
}
[data-bs-theme="dark"] {
--bs-body-bg: rgb(27, 28, 30);
--bs-tertiary-bg-rgb: 36, 38, 40;
}
@font-face {
font-family: Geist;
src: url("./geist/geist_variable.ttf");
}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Team Invitation{% endblock %}
{% block main %}
<main class="container mt-5">
<section class="mb-4">
<h1>Team Invitation</h1>
<p>
{{ invitation.creator.email }} has invited you to join their team,
<code>{{ invitation.team.name }}</code>. This will let you send messages,
manage messaging channels, invite team members, and more.
</p>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input
type="hidden"
name="verification_code"
value="{{ invitation.invitation.verification_code }}"
>
<button class="btn btn-primary" type="submit">Join Team</button>
</form>
</section>
</main>
{% endblock %}

View file

@ -4,6 +4,7 @@
<title>{% block title %}Shout.dev{% endblock %}</title>
{% include "meta_tags.html" %}
<link rel="stylesheet" href="{{ base_path }}/bootstrap-5.3.3-dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ base_path }}/main.css">
</head>
<body>
{% include "nav.html" %}

View file

@ -1,6 +1,8 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<a class="navbar-brand" href="#">Shout.dev</a>
<a class="navbar-brand" href="{{ base_path }}/" style="line-height: 0;">
<img src="{{ base_path }}/logo.svg" alt="Shout.dev" title="Shout.dev" width="32" height="32">
</a>
<button
class="navbar-toggler"
type="button"

241
templates/team-members.html Normal file
View file

@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Team Members{% endblock %}
{% block main %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<div class="d-flex justify-content-between align-items-center">
<h1>Team Members</h1>
<div>
<div class="dropdown">
<button
class="btn btn-primary"
type="button"
data-bs-toggle="modal"
data-bs-target="#invite-modal"
>
Invite
</button>
<div
class="modal fade"
id="invite-modal"
tabindex="-1"
aria-labelledby="invite-modal-label"
aria-hidden="true"
>
<div class="modal-dialog">
<form
method="post"
action="{{ breadcrumbs.join("../invite-team-member") }}"
>
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="invite-modal-label">
Invite to Team
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="invite-modal-email-input" class="form-label">
Email address
</label>
<input
type="text"
name="email"
inputmode="email"
class="form-control"
id="invite-modal-email-input"
aria-describedby="invite-modal-email-help"
>
<div id="invite-modal-email-help" class="form-text">
Invite link will be sent to this email address. The
recipient will need to open the link and create an
account (if they have not already) in order to join the
team.
</div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">Invite</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="mb-3">
<table class="table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for invitation in invitations %}
<tr>
<td>{{ invitation.invitation.email }}</td>
<td>Invited</td>
<td>
<button
class="btn btn-sm btn-outline-danger"
type="button"
data-bs-toggle="modal"
data-bs-target="#confirm-remove-invite-modal-{{ invitation.invitation.id }}"
>
Remove
</button>
<div
class="modal fade"
id="confirm-remove-invite-modal-{{ invitation.invitation.id }}"
tabindex="-1"
aria-labelledby="confirm-remove-invite-label-{{ invitation.invitation.id }}"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1
class="modal-title fs-5"
id="confirm-remove-invite-label-{{ invitation.invitation.id }}"
>
Confirm
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
Are you sure you want to cancel the invitation to
<code>{{ invitation.invitation.email }}</code>
from <code>{{ team_name }}</code>?
</div>
<div class="modal-footer">
<form
method="post"
action="{{ breadcrumbs.join("../remove-team-invitation") }}"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input
type="hidden"
name="invitation_id"
value="{{ invitation.invitation.id }}"
>
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-danger">Remove</button>
</form>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
{% for user in team_members %}
<tr>
<td>{{ user.email }}</td>
<td>Owner</td>
<td>
{% if team_members.len() > 1 %}
<button
class="btn btn-sm btn-outline-danger"
type="button"
data-bs-toggle="modal"
data-bs-target="#confirm-remove-member-modal-{{ user.id }}"
>
{% if user.id == current_user.id %}
Leave
{% else %}
Remove
{% endif %}
</button>
{% endif %}
<div
class="modal fade"
id="confirm-remove-member-modal-{{ user.id }}"
tabindex="-1"
aria-labelledby="confirm-remove-member-label-{{ user.id }}"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1
class="modal-title fs-5"
id="confirm-remove-member-label-{{ user.id }}"
>
Confirm
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
Are you sure you want to remove
{% if user.id == current_user.id %}
yourself
{% else %}
<code>{{ user.email }}</code>
{% endif %}
from <code>{{ team_name }}</code>?
</div>
<div class="modal-footer">
<form
method="post"
action="{{ breadcrumbs.join("../remove-team-member") }}"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="user_id" value="{{ user.id }}">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-danger">Remove</button>
</form>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
{% endblock %}

View file

@ -24,7 +24,7 @@
{% for team in teams %}
<tr>
<td>
<a href="{{ base_path }}/en/teams/{{ team.id }}">
<a href="{{ base_path }}/en/teams/{{ team.id.simple() }}">
{{ team.name }}
</a>
</td>