Merge remote-tracking branch 'origin/main' into feat/elasticsearch

Signed-off-by: Zomatree <me@zomatree.live>
This commit is contained in:
Zomatree
2026-06-25 06:19:17 +01:00
241 changed files with 118635 additions and 7140 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-delta"
version = "0.12.1"
version = "0.13.7"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
@@ -10,85 +10,81 @@ publish = false
[dependencies]
# Test
rand = "0.8.5"
redis-kiss = "0.1.4"
rand = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
# Utility
lru = "0.7.0"
url = "2.2.2"
log = "0.4.11"
dashmap = "5.2.0"
linkify = "0.6.0"
once_cell = "1.17.1"
env_logger = "0.7.1"
lru = { workspace = true }
url = { workspace = true }
log = { workspace = true }
dashmap = { workspace = true }
linkify = { workspace = true }
once_cell = { workspace = true }
# Lang. Utilities
regex = "1"
num_enum = "0.5.1"
impl_ops = "0.1.1"
bitfield = "0.13.2"
regex = { workspace = true }
num_enum = { workspace = true }
impl_ops = { workspace = true }
bitfield = { workspace = true }
# ID / key generation
ulid = "0.4.1"
nanoid = "0.4.0"
ulid = { workspace = true }
nanoid = { workspace = true }
# serde
serde_json = "1.0.57"
serde = { version = "1.0.115", features = ["derive"] }
validator = { version = "0.16", features = ["derive"] }
iso8601-timestamp = { version = "0.2.11", features = [] }
serde_json = { workspace = true }
serde = { workspace = true }
validator = { workspace = true, features = ["derive"] }
iso8601-timestamp = { workspace = true }
# async
futures = "0.3.8"
chrono = "0.4.15"
async-channel = "1.6.1"
reqwest = { version = "0.11.4", features = ["json"] }
async-std = { version = "1.8.0", features = [
"tokio1",
"tokio02",
"attributes",
] }
futures = { workspace = true }
chrono = { workspace = true }
async-channel = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
tokio = { workspace = true }
# internal util
lettre = "0.10.0-alpha.4"
lettre = { workspace = true }
# web
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_empty = { version = "0.1.1", features = ["schema"] }
rocket_authifier = { version = "1.0.16" }
rocket_prometheus = "0.10.0-rc.3"
rocket = { workspace = true, features = ["json"] }
rocket_cors = { workspace = true }
rocket_empty = { workspace = true, features = ["schema"] }
rocket_prometheus = { workspace = true }
# spec generation
schemars = "0.8.8"
revolt_rocket_okapi = { version = "0.10.0", features = ["swagger"] }
schemars = { workspace = true }
revolt_rocket_okapi = { workspace = true, features = ["swagger"] }
# rabbit
amqprs = { version = "1.7.0" }
lapin = { workspace = true, features = ["tokio"] }
# core
authifier = "1.0.16"
revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database", features = [
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = [
"rocket-impl",
"redis-is-patched",
"voice",
] }
revolt-models = { path = "../core/models", features = [
revolt-models = { workspace = true, features = [
"schemas",
"validator",
"rocket",
] }
revolt-presence = { path = "../core/presence" }
revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
revolt-search = { path = "../core/search" }
revolt-presence = { workspace = true }
revolt-result = { workspace = true, features = ["rocket", "okapi"] }
revolt-permissions = { workspace = true, features = ["schemas"] }
revolt-ratelimits = { workspace = true, features = ["rocket"] }
revolt-search = { workspace = true }
# voice
livekit-api = "0.4.4"
livekit-protocol = "0.4.0"
livekit-api = { workspace = true }
livekit-protocol = { workspace = true }
[dev-dependencies]
revolt-config = { workspace = true, features = ["test"] }
[build-dependencies]
vergen = "7.5.0"
vergen = { workspace = true }

View File

@@ -9,7 +9,6 @@ pub mod routes;
pub mod util;
use revolt_config::config;
use revolt_database::events::client::EventV1;
use revolt_database::AMQP;
use revolt_ratelimits::rocket as ratelimiter;
use revolt_search::ElasticsearchClient;
@@ -19,12 +18,6 @@ use rocket_prometheus::PrometheusMetrics;
use std::net::Ipv4Addr;
use std::str::FromStr;
use amqprs::{
channel::ExchangeDeclareArguments,
connection::{Connection, OpenConnectionArguments},
};
use async_std::channel::unbounded;
use authifier::AuthifierEvent;
use revolt_database::voice::VoiceClient;
use rocket::data::ToByteUnit;
@@ -37,31 +30,8 @@ pub async fn web() -> Rocket<Build> {
// Setup database
let db = revolt_database::DatabaseInfo::Auto.connect().await.unwrap();
log::info!("database_here {db:?}");
db.migrate_database().await.unwrap();
// Setup Authifier event channel
let (_, receiver) = unbounded();
// Setup Authifier
let authifier = db.clone().to_authifier().await;
// Launch a listener for Authifier events
async_std::task::spawn(async move {
while let Ok(event) = receiver.recv().await {
match &event {
AuthifierEvent::CreateSession { .. } | AuthifierEvent::CreateAccount { .. } => {
EventV1::Auth(event).global().await
}
AuthifierEvent::DeleteSession { user_id, .. }
| AuthifierEvent::DeleteAllSessions { user_id, .. } => {
let id = user_id.to_string();
EventV1::Auth(event).private(id).await
}
}
}
});
// Configure CORS
let cors = CorsOptions {
allowed_origins: AllowedOrigins::All,
@@ -94,58 +64,11 @@ pub async fn web() -> Rocket<Build> {
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
// Voice handler
let voice_client = VoiceClient::new(config.api.livekit.nodes.clone());
// Configure Rabbit
let connection = Connection::open(&OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
))
.await
.expect("Failed to connect to RabbitMQ");
let channel = connection
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.elasticsearch.exchange, "direct")
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
let amqp = AMQP::new(connection, channel);
let amqp = AMQP::new_auto().await;
// Launch background task workers
revolt_database::tasks::start_workers(db.clone(), amqp.clone());
@@ -170,8 +93,6 @@ pub async fn web() -> Rocket<Build> {
.mount("/", rocket_cors::catch_all_options_routes())
.mount("/", ratelimiter::routes())
.mount("/swagger/", swagger)
.mount("/0.8/swagger/", swagger_0_8)
.manage(authifier)
.manage(db)
.manage(amqp)
.manage(cors.clone())
@@ -184,6 +105,7 @@ pub async fn web() -> Rocket<Build> {
limits: rocket::data::Limits::default().limit("string", 5.megabytes()),
address: Ipv4Addr::new(0, 0, 0, 0).into(),
port: 14702,
ip_header: Some("X-Forwarded-For".into()),
..Default::default()
})
}

View File

@@ -0,0 +1,187 @@
//! Change account email.
//! PATCH /account/change/email
use revolt_database::util::email::validate_email;
use revolt_database::{Account, Database, ValidatedTicket};
use revolt_models::v0;
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
use revolt_result::{Result, create_error};
/// # Change Email
///
/// Change the associated account email.
#[openapi(tag = "Account")]
#[patch("/change/email", data = "<data>")]
pub async fn change_email(
db: &State<Database>,
validated_ticket: Option<ValidatedTicket>,
mut account: Account,
data: Json<v0::DataChangeEmail>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
validate_email(&data.email)?;
if account.mfa.is_active() && validated_ticket.is_none() {
return Err(create_error!(InvalidCredentials));
}
// Ensure given password is correct
account.verify_password(&data.current_password)?;
// Send email verification for new email
account
.start_email_move(db, data.email)
.await
.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_config::overwrite_config;
use revolt_database::{MFATicket, Totp};
use revolt_models::v0;
use rocket::http::{ContentType, Header, Status};
#[rocket::async_test]
async fn success() {
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let res = harness.client
.patch("/auth/account/change/email")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"email": "validexample@valid.com",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let account = harness.db.fetch_account(&account.id).await.unwrap();
assert_eq!(account.email, "validexample@valid.com");
}
#[rocket::async_test]
async fn success_smtp() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let res = harness.client
.patch("/auth/account/change/email")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"email": "change_email@smtp.test",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let account = harness.db.fetch_account(&account.id).await.unwrap();
let (_, code) = harness.assert_email("change_email@smtp.test").await;
let res = harness.client
.post(format!("/auth/account/verify/{}", code))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let account = harness.db.fetch_account(&account.id).await.unwrap();
assert_eq!(account.email, "change_email@smtp.test");
// Ensure that we did not receive a ticket
assert_eq!(
v0::ResponseVerify::NoTicket,
res.into_json().await.expect("`ResponseVerify")
)
}
#[rocket::async_test]
async fn success_mfa() {
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.patch("/auth/account/change/email")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.body(
json!({
"email": "validexample@valid.com",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let account = harness.db.fetch_account(&account.id).await.unwrap();
assert_eq!(account.email, "validexample@valid.com");
}
#[rocket::async_test]
async fn fail_mfa() {
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness.client
.patch("/auth/account/change/email")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"email": "validexample@valid.com",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
}

View File

@@ -0,0 +1,187 @@
//! Change account password.
//! PATCH /account/change/password
use revolt_database::{
util::password::{assert_safe, hash_password},
Account, Database, ValidatedTicket,
};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Change Password
///
/// Change the current account password.
#[openapi(tag = "Account")]
#[patch("/change/password", data = "<data>")]
pub async fn change_password(
db: &State<Database>,
validated_ticket: Option<ValidatedTicket>,
mut account: Account,
data: Json<v0::DataChangePassword>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
if account.mfa.is_active() && validated_ticket.is_none() {
return Err(create_error!(InvalidCredentials));
}
// Verify password can be used
assert_safe(&data.password).await?;
// Ensure given password is correct
account.verify_password(&data.current_password)?;
// Hash and replace password
account.password = hash_password(data.password)?;
// Commit to database
account.save(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, Totp};
use rocket::http::{ContentType, Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness
.client
.patch("/auth/account/change/password")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"password": "new password",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let res = harness
.client
.patch("/auth/account/change/password")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token))
.body(
json!({
"password": "sussy password",
"current_password": "new password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
#[rocket::async_test]
async fn success_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness
.client
.patch("/auth/account/change/password")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.body(
json!({
"password": "new password",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
#[rocket::async_test]
async fn fail_mfa_no_ticket() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness
.client
.patch("/auth/account/change/password")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"password": "new password",
"current_password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
#[rocket::async_test]
async fn fail_mfa_invalid_password() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let mut ticket = MFATicket::new(account.id.to_string(), true);
ticket.last_totp_code = Some("token from earlier".into());
ticket.save(&harness.db).await.unwrap();
let res = harness
.client
.patch("/auth/account/change/password")
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.body(
json!({
"password": "new password",
"current_password": "incorrect password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
}

View File

@@ -0,0 +1,59 @@
//! Confirm an account deletion.
//! PUT /account/delete
use revolt_database::Database;
use revolt_models::v0;
use revolt_result::Result;
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Confirm Account Deletion
///
/// Schedule an account for deletion by confirming the received token.
#[openapi(tag = "Account")]
#[put("/delete", data = "<data>")]
pub async fn confirm_deletion(
db: &State<Database>,
data: Json<v0::DataAccountDeletion>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
// Find the relevant account
let mut account = db.fetch_account_with_deletion_token(&data.token).await?;
// Schedule the account for deletion
account.schedule_deletion(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use iso8601_timestamp::{Duration, Timestamp};
use revolt_database::DeletionInfo;
use revolt_models::v0;
use rocket::http::Status;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (mut account, _, _) = harness.new_user().await;
account.deletion = Some(DeletionInfo::WaitingForVerification {
token: "token".to_string(),
expiry: Timestamp::now_utc() + Duration::seconds(100),
});
account.save(&harness.db).await.unwrap();
let res = harness
.client
.put("/auth/account/delete")
.json(&v0::DataAccountDeletion {
token: "token".to_string(),
})
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
}

View File

@@ -0,0 +1,347 @@
//! Create a new account
//! POST /account/create
use std::time::Duration;
use tokio::time::sleep;
use revolt_config::config;
use revolt_database::{
util::{
captcha::check_captcha,
email::validate_email,
password::assert_safe,
shield::{validate_shield, ShieldValidationInput},
},
Account, Database,
};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Create Account
///
/// Create a new account.
#[openapi(tag = "Account")]
#[post("/create", data = "<data>")]
pub async fn create_account(
db: &State<Database>,
data: Json<v0::DataCreateAccount>,
mut shield: ShieldValidationInput,
) -> Result<EmptyResponse> {
let data = data.into_inner();
// Random jitter from 0-1000ms
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
// Check Captcha token
check_captcha(data.captcha.as_deref()).await?;
// Validate the request
shield.email = Some(data.email.to_string());
validate_shield(shield).await?;
// Make sure email is valid and not blocked
validate_email(&data.email)?;
// Ensure password is safe to use
assert_safe(&data.password).await?;
// If required, fetch valid invite
let invite = if config().await.api.registration.invite_only {
if let Some(invite) = data.invite {
Some(db.fetch_account_invite(&invite).await?)
} else {
return Err(create_error!(MissingInvite));
}
} else {
None
};
// Create account
let account = Account::new(db, data.email, data.password, true).await?;
// Use up the invite
if let Some(mut invite) = invite {
invite.claimed_by = Some(account.id);
invite.used = true;
db.save_account_invite(&invite).await?;
}
Ok(EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_config::overwrite_config;
use revolt_database::{events::client::EventV1, AccountInvite};
use revolt_result::{Error, ErrorType};
use rocket::http::{ContentType, Status};
#[rocket::async_test]
async fn success() {
let mut harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "success@validemail.com",
"password": "valid password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
drop(res);
harness
.wait_for_event("global", |e| matches!(e, EventV1::CreateAccount { .. }))
.await;
}
#[rocket::async_test]
async fn fail_invalid_email() {
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "invalid",
"password": "valid password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::IncorrectData { .. },
));
}
#[rocket::async_test]
async fn fail_invalid_password() {
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "fail_invalid_password@validemail.com",
"password": "password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::CompromisedPassword,
));
}
#[rocket::async_test]
async fn fail_invalid_invite() {
overwrite_config(|config| config.api.registration.invite_only = true).await;
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "fail_invalid_invite@validemail.com",
"password": "valid password",
"invite": "invalid"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidInvite,
));
}
#[rocket::async_test]
async fn success_valid_invite() {
overwrite_config(|config| config.api.registration.invite_only = true).await;
let harness = TestHarness::new().await;
let invite = AccountInvite {
id: "invite".to_string(),
used: false,
claimed_by: None,
};
invite.save(&harness.db).await.unwrap();
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "success_valid_invite@validemail.com",
"password": "valid password",
"invite": "invite"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let invite = harness
.db
.fetch_account_invite("invite")
.await
.expect("`Invite`");
assert!(invite.used);
}
#[rocket::async_test]
async fn fail_missing_captcha() {
overwrite_config(|config| {
config.api.security.captcha.hcaptcha_key =
"0x0000000000000000000000000000000000000000".to_string()
})
.await;
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "fail_missing_captcha@validemail.com",
"password": "valid password",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::CaptchaFailed,
));
}
#[rocket::async_test]
async fn fail_captcha_invalid() {
overwrite_config(|config| {
config.api.security.captcha.hcaptcha_key =
"0x0000000000000000000000000000000000000000".to_string()
})
.await;
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "fail_captcha_invalid@validemail.com",
"password": "valid password",
"captcha": "00000000-aaaa-bbbb-cccc-000000000000"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::CaptchaFailed,
));
}
#[rocket::async_test]
async fn success_captcha_valid() {
overwrite_config(|config| {
config.api.security.captcha.hcaptcha_key =
"0x0000000000000000000000000000000000000000".to_string()
})
.await;
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "success_captcha_valid@validemail.com",
"password": "valid password",
"captcha": "20000000-aaaa-bbbb-cccc-000000000002"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
#[rocket::async_test]
async fn success_smtp_sent() {
let harness = TestHarness::new().await;
let res = harness
.client
.post("/auth/account/create")
.header(ContentType::JSON)
.body(
json!({
"email": "success_smtp_sent@smtp.test",
"password": "valid password",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let (_, code) = harness.assert_email("success_smtp_sent@smtp.test").await;
let res = harness
.client
.post(format!("/auth/account/verify/{code}"))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
}
}

View File

@@ -0,0 +1,64 @@
//! Delete an account.
//! POST /account/delete
use rocket::State;
use rocket_empty::EmptyResponse;
use revolt_result::Result;
use revolt_database::{Database, Account, ValidatedTicket};
/// # Delete Account
///
/// Request to have an account deleted.
#[openapi(tag = "Account")]
#[post("/delete")]
pub async fn delete_account(
db: &State<Database>,
mut account: Account,
_ticket: ValidatedTicket,
) -> Result<EmptyResponse> {
account
.start_account_deletion(db)
.await
.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::MFATicket;
use rocket::http::{ContentType, Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.email = "delete_account@smtp.test".to_string();
account.save(&harness.db).await.unwrap();
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/account/delete")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let (_, code) = harness.assert_email("delete_account@smtp.test").await;
let res = harness.client
.put("/auth/account/delete")
.header(ContentType::JSON)
.body(
json!({
"token": code
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
}

View File

@@ -0,0 +1,67 @@
//! Disable an account.
//! POST /account/disable
use revolt_result::Result;
use revolt_database::{Database, Account, ValidatedTicket};
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Disable Account
///
/// Disable an account.
#[openapi(tag = "Account")]
#[post("/disable")]
pub async fn disable_account(
db: &State<Database>,
mut account: Account,
_ticket: ValidatedTicket,
) -> Result<EmptyResponse> {
account.disable(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, events::client::EventV1};
use revolt_result::ErrorType;
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let mut harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/account/disable")
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
drop(res);
assert!(
harness.db
.fetch_account(&account.id)
.await
.unwrap()
.disabled
);
assert!(matches!(
harness.db
.fetch_session(&session.id)
.await
.unwrap_err().error_type,
ErrorType::UnknownUser
));
harness.wait_for_event(&format!("{}!", &account.id), |e| if let EventV1::DeleteAllSessions { user_id, .. } = e {
user_id == &account.id
} else {
false
}).await;
}
}

View File

@@ -0,0 +1,40 @@
//! Fetch your account
//! GET /account
use revolt_database::Account;
use rocket::serde::json::Json;
use revolt_models::v0;
use revolt_result::Result;
/// # Fetch Account
///
/// Fetch account information from the current session.
#[openapi(tag = "Account")]
#[get("/")]
pub async fn fetch_account(account: Account) -> Result<Json<v0::AccountInfo>> {
Ok(Json(account.into()))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_models::v0;
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let res = harness.client
.get("/auth/account")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(
&res.into_json::<v0::AccountInfo>().await.unwrap().id,
&account.id
);
}
}

View File

@@ -0,0 +1,30 @@
use rocket::Route;
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
pub mod change_email;
pub mod change_password;
pub mod confirm_deletion;
pub mod create_account;
pub mod delete_account;
pub mod disable_account;
pub mod fetch_account;
pub mod password_reset;
pub mod resend_verification;
pub mod send_password_reset;
pub mod verify_email;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
create_account::create_account,
resend_verification::resend_verification,
confirm_deletion::confirm_deletion,
fetch_account::fetch_account,
delete_account::delete_account,
disable_account::disable_account,
change_password::change_password,
change_email::change_email,
verify_email::verify_email,
password_reset::password_reset,
send_password_reset::send_password_reset
]
}

View File

@@ -0,0 +1,140 @@
//! Confirm a password reset.
//! PATCH /account/reset_password
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
use revolt_database::util::password::{hash_password, assert_safe};
use revolt_database::{Database};
use revolt_models::v0;
use revolt_result::Result;
/// # Password Reset
///
/// Confirm password reset and change the password.
#[openapi(tag = "Account")]
#[patch("/reset_password", data = "<data>")]
pub async fn password_reset(
db: &State<Database>,
data: Json<v0::DataPasswordReset>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
// Find the relevant account
let mut account = db
.fetch_account_with_password_reset(&data.token)
.await?;
// Verify password can be used
assert_safe(&data.password)
.await?;
// Update the account
account.password = hash_password(data.password)?;
account.password_reset = None;
account.lockout = None;
// Commit to database
account.save(db).await?;
// Delete all sessions if required
if data.remove_sessions {
account.delete_all_sessions(db, None).await?;
}
Ok(EmptyResponse)
}
#[cfg(test)]
mod tests {
use iso8601_timestamp::{Timestamp, Duration};
use revolt_database::PasswordReset;
use revolt_models::v0;
use revolt_result::{ErrorType, Error};
use crate::{rocket, util::test::TestHarness};
use rocket::http::{ContentType, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.password_reset = Some(PasswordReset {
token: "token".into(),
expiry: Timestamp::now_utc() + Duration::seconds(100),
});
account.save(&harness.db).await.unwrap();
let res = harness.client
.patch("/auth/account/reset_password")
.header(ContentType::JSON)
.body(
json!({
"token": "token",
"password": "valid-password",
"remove_sessions": true
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
// Make sure it was used and can't be used again
assert!(harness.db
.fetch_account_with_password_reset("token")
.await
.is_err());
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": account.email.clone(),
"password": "valid-password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::Session>().await.is_some());
// Ensure sessions were deleted
assert!(matches!(
harness
.db
.fetch_session(&session.id)
.await
.unwrap_err().error_type,
ErrorType::UnknownUser
));
}
#[rocket::async_test]
async fn fail_invalid_token() {
let harness = TestHarness::new().await;
let res = harness.client
.patch("/auth/account/reset_password")
.header(ContentType::JSON)
.body(
json!({
"token": "invalid",
"password": "valid password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidToken
));
}
}

View File

@@ -0,0 +1,155 @@
//! Resend account verification email
//! POST /account/reverify
use std::time::Duration;
use tokio::time::sleep;
use rocket::{serde::json::Json, State};
use rocket_empty::EmptyResponse;
use revolt_result::Result;
use revolt_database::{Database, util::{email::{normalise_email, validate_email}, captcha::check_captcha}, EmailVerification};
use revolt_models::v0;
/// # Resend Verification
///
/// Resend account creation verification email.
#[openapi(tag = "Account")]
#[post("/reverify", data = "<data>")]
pub async fn resend_verification(
db: &State<Database>,
data: Json<v0::DataResendVerification>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
// Random jitter from 0-1000ms
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
// Check Captcha token
check_captcha(data.captcha.as_deref()).await?;
// Make sure email is valid and not blocked
validate_email(&data.email)?;
// From this point on, do not report failure to the
// remote client, as this will open us up to user enumeration.
// Normalise the email
let email_normalised = normalise_email(data.email);
// Try to find the relevant account
if let Ok(Some(mut account)) = db
.fetch_account_by_normalised_email(&email_normalised)
.await
{
match account.verification {
EmailVerification::Verified => {
// Send password reset if already verified
account.start_password_reset(db, true).await?;
}
EmailVerification::Pending { .. } => {
// Resend if not verified yet
account.start_email_verification(db).await?;
}
// Ignore if pending for another email,
// this should be re-initiated from settings.
EmailVerification::Moving { .. } => {}
}
}
// Never fail this route,
// You may open the application to email enumeration otherwise.
Ok(EmptyResponse)
}
#[cfg(test)]
mod tests {
use iso8601_timestamp::Timestamp;
use revolt_database::{Account, EmailVerification};
use crate::{rocket, util::test::TestHarness};
use rocket::http::{ContentType, Status};
use revolt_result::{Error, ErrorType};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let mut account = Account::new(
&harness.db,
"resend_verification@smtp.test".into(),
"password".into(),
false,
)
.await
.unwrap();
account.verification = EmailVerification::Pending {
token: "".into(),
expiry: Timestamp::now_utc(),
};
account.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/account/reverify")
.header(ContentType::JSON)
.body(
json!({
"email": "resend_verification@smtp.test",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let (_, code) = harness.assert_email("resend_verification@smtp.test").await;
let res = harness.client
.post(format!("/auth/account/verify/{code}"))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
}
#[rocket::async_test]
async fn success_unknown() {
let harness = TestHarness::new().await;
let res = harness.client
.post("/auth/account/reverify")
.header(ContentType::JSON)
.body(
json!({
"email": "smtptest1@insrt.uk",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
#[rocket::async_test]
async fn fail_bad_email() {
let harness = TestHarness::new().await;
let res = harness.client
.post("/auth/account/reverify")
.header(ContentType::JSON)
.body(
json!({
"email": "invalid",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::IncorrectData { .. },
));
}
}

View File

@@ -0,0 +1,122 @@
//! Send a password reset email
//! POST /account/reset_password
use std::time::Duration;
use tokio::time::sleep;
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
use revolt_result::Result;
use revolt_database::{Database, EmailVerification, util::{email::{normalise_email, validate_email}, captcha::check_captcha}};
use revolt_models::v0;
/// # Send Password Reset
///
/// Send an email to reset account password.
#[openapi(tag = "Account")]
#[post("/reset_password", data = "<data>")]
pub async fn send_password_reset(
db: &State<Database>,
data: Json<v0::DataSendPasswordReset>,
) -> Result<EmptyResponse> {
let data = data.into_inner();
// Random jitter from 0-1000ms
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
// Check Captcha token
check_captcha(data.captcha.as_deref()).await?;
// Make sure email is valid and not blocked
validate_email(&data.email)?;
// From this point on, do not report failure to the
// remote client, as this will open us up to user enumeration.
// Normalise the email
let email_normalised = normalise_email(data.email);
// Try to find the relevant account
if let Ok(Some(mut account)) = db
.fetch_account_by_normalised_email(&email_normalised)
.await
{
if !matches!(account.verification, EmailVerification::Pending { .. }) {
if let Err(e) = account.start_password_reset(db, false).await {
revolt_config::capture_error(&e);
}
}
}
// Never fail this route, (except for db error)
// You may open the application to email enumeration otherwise.
Ok(EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::Account;
use revolt_models::v0;
use rocket::http::{ContentType, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
Account::new(
&harness.db,
"password_reset@smtp.test".into(),
"password".into(),
false,
)
.await
.unwrap();
let res = harness.client
.post("/auth/account/reset_password")
.header(ContentType::JSON)
.body(
json!({
"email": "password_reset@smtp.test",
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let (_, code) = harness.assert_email("password_reset@smtp.test").await;
let res = harness.client
.patch("/auth/account/reset_password")
.header(ContentType::JSON)
.body(
json!({
"token": code,
"password": "valid password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "password_reset@smtp.test",
"password": "valid password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
}
}

View File

@@ -0,0 +1,104 @@
//! Verify an account
//! POST /verify/<code>
use rocket::{serde::json::Json, State};
use revolt_database::{Database, EmailVerification, MFATicket, util::email::normalise_email};
use revolt_result::Result;
use revolt_models::v0;
/// # Verify Email
///
/// Verify an email address.
#[openapi(tag = "Account")]
#[post("/verify/<code>")]
pub async fn verify_email(
db: &State<Database>,
code: String,
) -> Result<Json<v0::ResponseVerify>> {
// Find the account
let mut account = db
.fetch_account_with_email_verification(&code)
.await?;
// Update account email
let response = if let EmailVerification::Moving { new_email, .. } = &account.verification {
account.email = new_email.clone();
account.email_normalised = normalise_email(new_email.clone());
v0::ResponseVerify::NoTicket
} else {
let mut ticket = MFATicket::new(account.id.to_string(), false);
ticket.authorised = true;
ticket.save(db).await?;
v0::ResponseVerify::WithTicket { ticket: ticket.into() }
};
// Mark as verified
account.verification = EmailVerification::Verified;
// Save to database
account.save(db).await?;
Ok(Json(response))
}
#[cfg(test)]
mod tests {
use iso8601_timestamp::{Timestamp, Duration};
use revolt_database::EmailVerification;
use crate::{rocket, util::test::TestHarness};
use rocket::http::{ContentType, Status};
use revolt_models::v0;
use revolt_result::{Error, ErrorType};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (mut account, _, _) = harness.new_user().await;
account.verification = EmailVerification::Pending {
token: "token".into(),
expiry: Timestamp::now_utc() + Duration::seconds(100),
};
account.save(&harness.db).await.unwrap();
let res = harness.client.post("/auth/account/verify/token").dispatch().await;
assert_eq!(res.status(), Status::Ok);
// Make sure it was used and can't be used again
assert!(harness.db
.fetch_account_with_email_verification("token")
.await
.is_err());
// Check that we can login using the received MFA ticket
let response = res.into_json::<v0::ResponseVerify>().await
.expect("`ResponseVerify`");
if let v0::ResponseVerify::WithTicket { ticket } = response {
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(json!({ "mfa_ticket": ticket.token }).to_string())
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::Session>().await.is_some());
} else {
panic!("Expected `ResponseVerify::WithTicket`");
}
}
#[rocket::async_test]
async fn fail_invalid_token() {
let harness = TestHarness::new().await;
let res = harness.client.post("/auth/account/verify/token").dispatch().await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidToken,
));
}
}

View File

@@ -1,6 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User,
Database, User, AMQP,
};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
@@ -14,6 +14,7 @@ use rocket_empty::EmptyResponse;
#[put("/<target>/ack/<message>")]
pub async fn ack(
db: &State<Database>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
message: Reference<'_>,
@@ -29,7 +30,7 @@ pub async fn ack(
.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?;
channel
.ack(&user.id, message.id)
.ack(&user.id, message.id, amqp)
.await
.map(|_| EmptyResponse)
}

View File

@@ -1,4 +1,5 @@
use chrono::Utc;
use std::time::Duration;
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, Message, User,
@@ -36,10 +37,9 @@ pub async fn bulk_delete_messages(
if ulid::Ulid::from_string(id)
.map_err(|_| create_error!(InvalidOperation))?
.datetime()
.signed_duration_since(Utc::now())
.num_days()
.abs()
> 7
.elapsed()
.expect("Time went backwards")
> Duration::from_hours(7 * 24) // 7 days
{
return Err(create_error!(InvalidOperation));
}

View File

@@ -1,11 +1,14 @@
use chrono::{Duration, Utc};
use std::time::Duration;
use redis_kiss::{get_connection, redis, AsyncCommands};
use revolt_database::events::client::EventV1;
use revolt_database::util::permissions::DatabasePermissionQuery;
use revolt_database::{
util::idempotency::IdempotencyKey, util::reference::Reference, Database, User,
};
use revolt_database::{Channel, Interactions, Message, AMQP};
use revolt_models::v0;
use revolt_models::v0::ChannelSlowmode;
use revolt_permissions::PermissionQuery;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
@@ -83,6 +86,16 @@ pub async fn message_send(
.await
.unwrap_or(None);
if set_result.is_some() {
let idx_key = format!("slowmode_idx:{}", user.id);
conn.sadd::<_, _, ()>(&idx_key, channel_id.as_str())
.await
.ok();
conn.expire::<_, ()>(&idx_key, *channel_slowmode as usize)
.await
.ok();
}
// If `set_result` is None, the `NX` condition failed because the key already exists.
// This means the user is currently in slowmode.
if set_result.is_none() {
@@ -91,10 +104,29 @@ pub async fn message_send(
// Redis returns positive integers for valid TTLs
if ttl > 0 {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: ttl as u64,
}],
}
.private(user.id.clone())
.await;
return Err(create_error!(InSlowmode {
retry_after: ttl as u64
}));
}
} else {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: *channel_slowmode,
}],
}
.private(user.id.clone())
.await;
}
}
// If Redis connection fails, just skip the slowmode check
@@ -111,8 +143,12 @@ pub async fn message_send(
// Disallow mentions for new users (TRUST-0: <12 hours age) in public servers
let allow_mentions = if let Some(server) = query.server_ref() {
if server.discoverable {
(Utc::now() - ulid::Ulid::from_string(&user.id).unwrap().datetime())
>= Duration::hours(12)
(ulid::Ulid::from_string(&user.id)
.unwrap()
.datetime()
.elapsed()
.expect("Time went backwards"))
>= Duration::from_hours(12)
} else {
true
}
@@ -324,6 +360,7 @@ mod test {
id: None,
joined_at: None,
nickname: None,
pronouns: None,
avatar: None,
timeout: None,
roles: Some(second_member_roles),
@@ -643,6 +680,7 @@ mod test {
id: None,
joined_at: None,
nickname: None,
pronouns: None,
roles: Some(vec![role.id.clone()]),
timeout: None,
can_publish: None,

View File

@@ -2,7 +2,7 @@ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, Override};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, Override, PermissionQuery};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
@@ -25,11 +25,15 @@ pub async fn set_role_permissions(
let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel);
let permissions: revolt_permissions::PermissionValue = calculate_channel_permissions(&mut query).await;
query.set_server_from_channel().await;
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?;
if let Some(server) = query.server_ref() {
if let Some(role) = server.roles.get(&role_id) {
if role.rank <= query.get_member_rank().unwrap_or(i64::MIN) {
if server.owner != user.id
&& role.rank <= query.get_member_rank().unwrap_or(i64::MIN)
{
return Err(create_error!(NotElevated));
}

View File

@@ -0,0 +1,212 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, EmojiParent, PartialEmoji, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
use validator::Validate;
/// # Edit Emoji
///
/// Edit an emoji by its id.
#[openapi(tag = "Emojis")]
#[patch("/emoji/<emoji_id>", data = "<data>")]
pub async fn edit_emoji(
db: &State<Database>,
user: User,
emoji_id: Reference<'_>,
data: Json<v0::DataEditEmoji>,
) -> Result<Json<v0::Emoji>> {
let data = data.into_inner();
data.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
let mut emoji = emoji_id.as_emoji(db).await?;
match &emoji.parent {
EmojiParent::Server { id } => {
let server = db.fetch_server(id.as_str()).await?;
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
calculate_server_permissions(&mut query)
.await
.throw_if_lacking_channel_permission(ChannelPermission::ManageCustomisation)?;
}
EmojiParent::Detached => return Err(create_error!(NotAuthenticated)),
}
if data.name.is_none() {
return Ok(Json(emoji.into()));
}
let partial = PartialEmoji { name: data.name };
emoji.update(db, partial).await?;
Ok(Json(emoji.into()))
}
#[cfg(test)]
mod test {
use crate::util::test::TestHarness;
use revolt_database::{Emoji, EmojiParent, Member};
use revolt_models::v0;
use rocket::http::{ContentType, Header, Status};
use ulid::Ulid;
#[rocket::async_test]
async fn edit_emoji_name_as_creator() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let (server, _) = harness.new_server(&user).await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: user.id.clone(),
name: "initial_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("renamed_emoji".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let edited: v0::Emoji = response.into_json().await.expect("`Emoji`");
assert_eq!(edited.name, "renamed_emoji");
}
#[rocket::async_test]
async fn reject_invalid_emoji_name() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let (server, _) = harness.new_server(&user).await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: user.id.clone(),
name: "valid_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("Invalid Name".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::BadRequest);
}
#[rocket::async_test]
async fn reject_edit_for_detached_emoji() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Detached,
creator_id: user.id.clone(),
name: "detached_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("should_not_apply".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Unauthorized);
}
#[rocket::async_test]
async fn reject_edit_for_creator_without_manage_customisation() {
let harness = TestHarness::new().await;
let (_, _, owner) = harness.new_user().await;
let (_, creator_session, creator) = harness.new_user().await;
let (server, _) = harness.new_server(&owner).await;
Member::create(&harness.db, &server, &creator, None)
.await
.expect("`Member` created");
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: creator.id.clone(),
name: "member_uploaded_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new(
"x-session-token",
creator_session.token.to_string(),
))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("renamed_without_permission".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Forbidden);
}
}

View File

@@ -3,12 +3,14 @@ use rocket::Route;
mod emoji_create;
mod emoji_delete;
mod emoji_edit;
mod emoji_fetch;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
emoji_create::create_emoji,
emoji_delete::delete_emoji,
emoji_edit::edit_emoji,
emoji_fetch::fetch_emoji
]
}

View File

@@ -0,0 +1,157 @@
//! Create a new MFA ticket or validate an existing one.
//! PUT /mfa/ticket
use revolt_result::{Result, create_error};
use revolt_database::{Account, Database, MFATicket, UnvalidatedTicket};
use revolt_models::v0;
use rocket::serde::json::Json;
use rocket::State;
/// # Create MFA ticket
///
/// Create a new MFA ticket or validate an existing one.
#[openapi(tag = "MFA")]
#[put("/ticket", data = "<data>")]
pub async fn create_ticket(
db: &State<Database>,
account: Option<Account>,
existing_ticket: Option<UnvalidatedTicket>,
data: Json<v0::MFAResponse>,
) -> Result<Json<v0::MFATicket>> {
// Find the relevant account
let mut account = match (account, existing_ticket) {
(Some(_), Some(_)) => return Err(create_error!(OperationFailed)),
(Some(account), _) => account,
(_, Some(ticket)) => {
db.delete_ticket(&ticket.id).await?;
db.fetch_account(&ticket.account_id).await?
}
_ => return Err(create_error!(InvalidToken)),
};
// Validate the MFA response
account
.consume_mfa_response(db, data.into_inner(), None)
.await?;
// Create a new ticket for this account
let ticket = MFATicket::new(account.id, true);
ticket.save(db).await?;
Ok(Json(ticket.into()))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::Totp;
use rocket::http::{Header, Status};
use revolt_models::v0;
use revolt_result::{Error, ErrorType};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness.client
.put("/auth/mfa/ticket")
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::MFATicket>().await.unwrap().validated);
}
#[rocket::async_test]
async fn success_totp() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.mfa.totp_token = Totp::Enabled {
secret: "secret".to_string(),
};
account.save(&harness.db).await.unwrap();
let res = harness.client
.put("/auth/mfa/ticket")
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"totp_code": Totp::Enabled {
secret: "secret".to_string(),
}.generate_code().unwrap()
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::MFATicket>().await.is_some());
}
#[rocket::async_test]
async fn failure_totp() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.mfa.totp_token = Totp::Enabled {
secret: "secret".to_string(),
};
account.save(&harness.db).await.unwrap();
let res = harness.client
.put("/auth/mfa/ticket")
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"totp_code": "000000"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidToken,
));
}
#[rocket::async_test]
async fn failure_no_totp() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.mfa.totp_token = Totp::Enabled {
secret: "secret".to_string(),
};
account.save(&harness.db).await.unwrap();
let res = harness.client
.put("/auth/mfa/ticket")
.header(Header::new("X-Session-Token", session.token.clone()))
.body(
json!({
"password": "this is the wrong mfa method"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::BadRequest);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::DisallowedMFAMethod,
));
}
}

View File

@@ -0,0 +1,45 @@
//! Fetch recovery codes for an account.
//! POST /mfa/recovery
use rocket::serde::json::Json;
use revolt_database::{Account, ValidatedTicket};
use revolt_result::Result;
/// # Fetch Recovery Codes
///
/// Fetch recovery codes for an account.
#[openapi(tag = "MFA")]
#[post("/recovery")]
pub async fn fetch_recovery(
account: Account,
_ticket: ValidatedTicket,
) -> Result<Json<Vec<String>>> {
Ok(Json(account.mfa.recovery_codes))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::MFATicket;
use rocket::http::{ContentType, Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id, true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/mfa/recovery")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.header(ContentType::JSON)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<Vec<String>>().await.unwrap().is_empty());
}
}

View File

@@ -0,0 +1,37 @@
//! Fetch MFA status of an account.
//! GET /mfa
use revolt_database::Account;
use revolt_result::Result;
use revolt_models::v0;
use rocket::serde::json::Json;
/// # MFA Status
///
/// Fetch MFA status of an account.
#[openapi(tag = "MFA")]
#[get("/")]
pub async fn fetch_status(account: Account) -> Result<Json<v0::MultiFactorStatus>> {
Ok(Json(account.mfa.into()))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use rocket::http::{Header, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness.client
.get("/auth/mfa")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::MultiFactorStatus>().await.is_some());
}
}

View File

@@ -0,0 +1,66 @@
//! Re-generate recovery codes for an account.
//! PATCH /mfa/recovery
use revolt_database::{Account, ValidatedTicket, Database};
use revolt_result::Result;
use rocket::serde::json::Json;
use rocket::State;
/// # Generate Recovery Codes
///
/// Re-generate recovery codes for an account.
#[openapi(tag = "MFA")]
#[patch("/recovery")]
pub async fn generate_recovery(
db: &State<Database>,
mut account: Account,
_ticket: ValidatedTicket,
) -> Result<Json<Vec<String>>> {
// Generate new codes
account.mfa.generate_recovery_codes();
// Save account model
account.save(db).await?;
// Return them to the user
Ok(Json(account.mfa.recovery_codes))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::MFATicket;
use rocket::http::{ContentType, Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket1 = MFATicket::new(account.id.to_string(), true);
ticket1.save(&harness.db).await.unwrap();
let ticket2 = MFATicket::new(account.id, true);
ticket2.save(&harness.db).await.unwrap();
let res = harness.client
.patch("/auth/mfa/recovery")
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket1.token))
.header(ContentType::JSON)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<Vec<String>>().await.is_some());
let res = harness.client
.post("/auth/mfa/recovery")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket2.token))
.header(ContentType::JSON)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(res.into_json::<Vec<String>>().await.unwrap().len(), 10);
}
}

View File

@@ -0,0 +1,71 @@
//! Fetch available MFA methods.
//! GET /mfa/methods
use revolt_database::Account;
use revolt_models::v0;
use rocket::serde::json::Json;
/// # Get MFA Methods
///
/// Fetch available MFA methods.
#[openapi(tag = "MFA")]
#[get("/methods")]
pub async fn get_mfa_methods(account: Account) -> Json<Vec<v0::MFAMethod>> {
Json(
account
.mfa
.get_methods()
.into_iter()
.map(Into::into)
.collect(),
)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::Totp;
use rocket::http::{Header, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness.client
.get("/auth/mfa/methods")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(
res.into_json::<Vec<v0::MFAMethod>>().await.unwrap(),
vec![v0::MFAMethod::Password]
);
}
#[rocket::async_test]
async fn success_has_recovery_and_totp() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
account.mfa.totp_token = Totp::Enabled {
secret: "some".to_string(),
};
account.mfa.generate_recovery_codes();
account.save(&harness.db).await.unwrap();
let res = harness.client
.get("/auth/mfa/methods")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(
res.into_json::<Vec<v0::MFAMethod>>().await.unwrap(),
vec![v0::MFAMethod::Totp, v0::MFAMethod::Recovery]
);
}
}

View File

@@ -0,0 +1,24 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
pub mod create_ticket;
pub mod fetch_recovery;
pub mod fetch_status;
pub mod generate_recovery;
pub mod get_mfa_methods;
pub mod totp_disable;
pub mod totp_enable;
pub mod totp_generate_secret;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
create_ticket::create_ticket,
fetch_status::fetch_status,
fetch_recovery::fetch_recovery,
generate_recovery::generate_recovery,
get_mfa_methods::get_mfa_methods,
totp_disable::totp_disable,
totp_enable::totp_enable,
totp_generate_secret::totp_generate_secret,
]
}

View File

@@ -0,0 +1,49 @@
//! Disable TOTP 2FA.
//! DELETE /mfa/totp
use revolt_database::{Database, Account, ValidatedTicket, Totp};
use revolt_result::Result;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Disable TOTP 2FA
///
/// Disable TOTP 2FA for an account.
#[openapi(tag = "MFA")]
#[delete("/totp")]
pub async fn totp_disable(
db: &State<Database>,
mut account: Account,
_ticket: ValidatedTicket,
) -> Result<EmptyResponse> {
// Disable TOTP
account.mfa.totp_token = Totp::Disabled;
// Save model to database
account.save(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::MFATicket;
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id, true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/mfa/totp")
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
}
}

View File

@@ -0,0 +1,102 @@
//! Generate a new secret for TOTP.
//! POST /mfa/totp
use revolt_database::{Database, Account};
use revolt_models::v0;
use revolt_result::Result;
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Enable TOTP 2FA
///
/// Generate a new secret for TOTP.
#[openapi(tag = "MFA")]
#[put("/totp", data = "<data>")]
pub async fn totp_enable(
db: &State<Database>,
mut account: Account,
data: Json<v0::MFAResponse>,
) -> Result<EmptyResponse> {
// Enable TOTP 2FA
account.mfa.enable_totp(data.into_inner())?;
// Save model to database
account.save(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, Totp};
use rocket::http::{ContentType, Header, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/mfa/totp")
.header(Header::new("X-Session-Token", session.token.clone()))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let secret = res.into_json::<v0::ResponseTotpSecret>().await.unwrap().secret;
let code = Totp::Enabled { secret }.generate_code().unwrap();
let res = harness.client
.put("/auth/mfa/totp")
.header(Header::new("X-Session-Token", session.token))
.header(ContentType::JSON)
.body(json!({ "totp_code": code }).to_string())
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": account.email.clone(),
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = res.into_json::<v0::ResponseLogin>().await.unwrap();
if let v0::ResponseLogin::MFA { ticket, .. } = response {
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"mfa_ticket": ticket,
"mfa_response": {
"totp_code": code
}
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
} else {
unreachable!("Did not receive MFA challenge!");
}
}
}

View File

@@ -0,0 +1,59 @@
//! Generate a new secret for TOTP.
//! POST /mfa/totp
use revolt_result::Result;
use revolt_models::v0;
use revolt_database::{Database, Account, ValidatedTicket};
use rocket::serde::json::Json;
use rocket::State;
/// # Generate TOTP Secret
///
/// Generate a new secret for TOTP.
#[openapi(tag = "MFA")]
#[post("/totp")]
pub async fn totp_generate_secret(
db: &State<Database>,
mut account: Account,
_ticket: ValidatedTicket,
) -> Result<Json<v0::ResponseTotpSecret>> {
// Generate a new secret
let secret = account.mfa.generate_new_totp_secret()?;
// Save model to database
account.save(db).await?;
// Send secret to user
Ok(Json(v0::ResponseTotpSecret { secret }))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, Totp};
use rocket::http::{Header, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/mfa/totp")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let secret = res.into_json::<v0::ResponseTotpSecret>().await.unwrap().secret;
let account = harness.db.fetch_account(&account.id).await.unwrap();
assert_eq!(account.mfa.totp_token, Totp::Pending { secret });
}
}

View File

@@ -18,6 +18,9 @@ mod sync;
mod users;
mod webhooks;
mod search;
mod account;
mod session;
mod mfa;
pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
let settings = OpenApiSettings::default();
@@ -34,9 +37,9 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
"/invites" => invites::routes(),
"/custom" => customisation::routes(),
"/safety" => safety::routes(),
"/auth/account" => rocket_authifier::routes::account::routes(),
"/auth/session" => rocket_authifier::routes::session::routes(),
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
"/auth/account" => account::routes(),
"/auth/session" => session::routes(),
"/auth/mfa" => mfa::routes(),
"/onboard" => onboard::routes(),
"/policy" => policy::routes(),
"/push" => push::routes(),
@@ -56,9 +59,9 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
"/invites" => invites::routes(),
"/custom" => customisation::routes(),
"/safety" => safety::routes(),
"/auth/account" => rocket_authifier::routes::account::routes(),
"/auth/session" => rocket_authifier::routes::session::routes(),
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
"/auth/account" => account::routes(),
"/auth/session" => session::routes(),
"/auth/mfa" => mfa::routes(),
"/onboard" => onboard::routes(),
"/policy" => policy::routes(),
"/push" => push::routes(),
@@ -67,49 +70,6 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
};
}
if config.features.webhooks_enabled {
mount_endpoints_and_merged_docs! {
rocket, "/0.8".to_owned(), settings,
"/" => (vec![], custom_openapi_spec()),
"" => openapi_get_routes_spec![root::root],
"/users" => users::routes(),
"/bots" => bots::routes(),
"/channels" => channels::routes(),
"/servers" => servers::routes(),
"/invites" => invites::routes(),
"/custom" => customisation::routes(),
"/safety" => safety::routes(),
"/auth/account" => rocket_authifier::routes::account::routes(),
"/auth/session" => rocket_authifier::routes::session::routes(),
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
"/onboard" => onboard::routes(),
"/push" => push::routes(),
"/sync" => sync::routes(),
"/webhooks" => webhooks::routes(),
"/search" => search::routes(),
};
} else {
mount_endpoints_and_merged_docs! {
rocket, "/0.8".to_owned(), settings,
"/" => (vec![], custom_openapi_spec()),
"" => openapi_get_routes_spec![root::root],
"/users" => users::routes(),
"/bots" => bots::routes(),
"/channels" => channels::routes(),
"/servers" => servers::routes(),
"/invites" => invites::routes(),
"/custom" => customisation::routes(),
"/safety" => safety::routes(),
"/auth/account" => rocket_authifier::routes::account::routes(),
"/auth/session" => rocket_authifier::routes::session::routes(),
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
"/onboard" => onboard::routes(),
"/push" => push::routes(),
"/sync" => sync::routes(),
"/search" => search::routes(),
};
}
rocket
}
@@ -120,8 +80,8 @@ fn custom_openapi_spec() -> OpenApi {
extensions.insert(
"x-logo".to_owned(),
json!({
"url": "https://revolt.chat/header.png",
"altText": "Revolt Header"
"url": "https://stoat.chat/header.png",
"altText": "Stoat Header"
}),
);
@@ -129,7 +89,7 @@ fn custom_openapi_spec() -> OpenApi {
"x-tagGroups".to_owned(),
json!([
{
"name": "Revolt",
"name": "Stoat",
"tags": [
"Core"
]
@@ -210,18 +170,21 @@ fn custom_openapi_spec() -> OpenApi {
OpenApi {
openapi: OpenApi::default_version(),
info: Info {
title: "Revolt API".to_owned(),
title: "Stoat API".to_owned(),
description: Some("Open source user-first chat platform.".to_owned()),
terms_of_service: Some("https://revolt.chat/terms".to_owned()),
terms_of_service: Some("https://stoat.chat/terms".to_owned()),
contact: Some(Contact {
name: Some("Revolt Support".to_owned()),
url: Some("https://revolt.chat".to_owned()),
email: Some("contact@revolt.chat".to_owned()),
name: Some("Stoat".to_owned()),
url: Some("https://stoat.chat".to_owned()),
email: Some("contact@stoat.chat".to_owned()),
..Default::default()
}),
license: Some(License {
name: "AGPLv3".to_owned(),
url: Some("https://github.com/stoatchat/stoatchat/blob/main/crates/delta/LICENSE".to_owned()),
url: Some(
"https://github.com/stoatchat/stoatchat/blob/main/crates/delta/LICENSE"
.to_owned(),
),
..Default::default()
}),
version: env!("CARGO_PKG_VERSION").to_string(),
@@ -229,29 +192,19 @@ fn custom_openapi_spec() -> OpenApi {
},
servers: vec![
Server {
url: "https://api.revolt.chat".to_owned(),
description: Some("Revolt Production".to_owned()),
url: "https://api.stoat.chat".to_owned(),
description: Some("Stoat Production".to_owned()),
..Default::default()
},
Server {
url: "https://revolt.chat/api".to_owned(),
description: Some("Revolt Staging".to_owned()),
..Default::default()
},
Server {
url: "http://local.revolt.chat:14702".to_owned(),
description: Some("Local Revolt Environment".to_owned()),
..Default::default()
},
Server {
url: "http://local.revolt.chat:14702/0.8".to_owned(),
description: Some("Local Revolt Environment (v0.8)".to_owned()),
url: "https://beta.stoat.chat/api".to_owned(),
description: Some("Stoat Beta".to_owned()),
..Default::default()
},
],
external_docs: Some(ExternalDocs {
url: "https://developers.revolt.chat".to_owned(),
description: Some("Revolt Developer Documentation".to_owned()),
url: "https://developers.stoat.chat".to_owned(),
description: Some("Stoat Developer Documentation".to_owned()),
..Default::default()
}),
extensions,
@@ -259,19 +212,19 @@ fn custom_openapi_spec() -> OpenApi {
Tag {
name: "Core".to_owned(),
description: Some(
"Use in your applications to determine information about the Revolt node"
"Use in your applications to determine information about the Stoat node"
.to_owned(),
),
..Default::default()
},
Tag {
name: "User Information".to_owned(),
description: Some("Query and fetch users on Revolt".to_owned()),
description: Some("Query and fetch users on Stoat".to_owned()),
..Default::default()
},
Tag {
name: "Direct Messaging".to_owned(),
description: Some("Direct message other users on Revolt".to_owned()),
description: Some("Direct message other users on Stoat".to_owned()),
..Default::default()
},
Tag {
@@ -288,7 +241,7 @@ fn custom_openapi_spec() -> OpenApi {
},
Tag {
name: "Channel Information".to_owned(),
description: Some("Query and fetch channels on Revolt".to_owned()),
description: Some("Query and fetch channels on Stoat".to_owned()),
..Default::default()
},
Tag {
@@ -318,7 +271,7 @@ fn custom_openapi_spec() -> OpenApi {
},
Tag {
name: "Server Information".to_owned(),
description: Some("Query and fetch servers on Revolt".to_owned()),
description: Some("Query and fetch servers on Stoat".to_owned()),
..Default::default()
},
Tag {
@@ -354,7 +307,7 @@ fn custom_openapi_spec() -> OpenApi {
Tag {
name: "Onboarding".to_owned(),
description: Some(
"After signing up to Revolt, users must pick a unique username".to_owned(),
"After signing up to Stoat, users must pick a unique username".to_owned(),
),
..Default::default()
},
@@ -366,7 +319,7 @@ fn custom_openapi_spec() -> OpenApi {
Tag {
name: "Web Push".to_owned(),
description: Some(
"Subscribe to and receive Revolt push notifications while offline".to_owned(),
"Subscribe to and receive Stoat push notifications while offline".to_owned(),
),
..Default::default()
},

View File

@@ -1,7 +1,6 @@
use authifier::models::Session;
use once_cell::sync::Lazy;
use regex::Regex;
use revolt_database::{Database, User};
use revolt_database::{Database, User, Session};
use revolt_models::v0;
use revolt_result::{create_error, Result};

View File

@@ -1,5 +1,4 @@
use authifier::models::Session;
use revolt_database::User;
use revolt_database::{Session, User};
use rocket::serde::json::Json;
use serde::Serialize;

View File

@@ -1,7 +1,5 @@
use authifier::{
models::{Session, WebPushSubscription},
Authifier,
};
use revolt_database::{Database, Session};
use revolt_models::v0;
use revolt_result::{create_database_error, Result};
use rocket::{serde::json::Json, State};
use rocket_empty::EmptyResponse;
@@ -14,13 +12,13 @@ use rocket_empty::EmptyResponse;
#[openapi(tag = "Web Push")]
#[post("/subscribe", data = "<data>")]
pub async fn subscribe(
authifier: &State<Authifier>,
db: &State<Database>,
mut session: Session,
data: Json<WebPushSubscription>,
data: Json<v0::WebPushSubscription>,
) -> Result<EmptyResponse> {
session.subscription = Some(data.into_inner());
session.subscription = Some(data.into_inner().into());
session
.save(authifier)
.save(db)
.await
.map(|_| EmptyResponse)
.map_err(|_| create_database_error!("save", "session"))

View File

@@ -1,5 +1,4 @@
use authifier::{models::Session, Authifier};
use revolt_database::{Database, Session};
use revolt_result::{create_database_error, Result};
use rocket_empty::EmptyResponse;
@@ -10,13 +9,10 @@ use rocket::State;
/// Remove the Web Push subscription associated with the current session.
#[openapi(tag = "Web Push")]
#[post("/unsubscribe")]
pub async fn unsubscribe(
authifier: &State<Authifier>,
mut session: Session,
) -> Result<EmptyResponse> {
pub async fn unsubscribe(db: &State<Database>, mut session: Session) -> Result<EmptyResponse> {
session.subscription = None;
session
.save(authifier)
.save(db)
.await
.map(|_| EmptyResponse)
.map_err(|_| create_database_error!("save", "session"))

View File

@@ -57,6 +57,8 @@ pub struct RevoltFeatures {
pub livekit: VoiceFeature,
/// Limits
pub limits: LimitsConfig,
/// Legal links
pub legal_links: LegalLinks,
}
/// # Limits For Users
@@ -70,6 +72,17 @@ pub struct LimitsConfig {
pub default: UserLimits,
}
/// # Legal links
#[derive(Serialize, JsonSchema, Debug)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
/// # Global limits
#[derive(Serialize, JsonSchema, Debug)]
pub struct GlobalLimits {
@@ -92,6 +105,8 @@ pub struct GlobalLimits {
/// restrict server creation to these users.
/// if blank, all users can create servers
pub restrict_server_creation: Vec<String>,
/// New user hours
new_user_hours: i64,
}
/// # User Limits
@@ -231,10 +246,16 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
.limits
.global
.restrict_server_creation,
new_user_hours: config.features.limits.global.new_user_hours as i64,
},
new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
default: UserLimits::from_feature_limits(config.features.limits.default),
},
legal_links: LegalLinks {
terms_of_service: config.features.legal_links.terms_of_service,
privacy_policy: config.features.legal_links.privacy_policy,
guidelines: config.features.legal_links.guidelines,
},
},
ws: config.hosts.events,
app: config.hosts.app,

View File

@@ -64,6 +64,12 @@ pub async fn edit(
}
}
if data.pronouns.is_some() || data.remove.contains(&v0::FieldsMember::Pronouns) {
if user.id != member.id.user {
return Err(create_error!(InvalidOperation))
}
}
if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) {
if user.id == member.id.user {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?;
@@ -172,6 +178,7 @@ pub async fn edit(
// Apply edits to the member object
let v0::DataMemberEdit {
nickname,
pronouns,
avatar,
roles,
timeout,
@@ -183,6 +190,7 @@ pub async fn edit(
let mut partial = PartialMember {
nickname,
pronouns,
roles,
timeout,
can_publish,

View File

@@ -1,7 +1,7 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{sync_voice_permissions, VoiceClient},
Database, PartialRole, User
Database, File, PartialRole, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -47,14 +47,27 @@ pub async fn edit(
name,
colour,
hoist,
icon,
remove,
..
} = data;
if remove.contains(&v0::FieldsRole::Icon) {
if let Some(existing_icon) = &role.icon {
db.mark_attachment_as_deleted(&existing_icon.id).await?;
}
}
let mut final_icon = None;
if let Some(icon_id) = icon {
final_icon = Some(File::use_role_icon(db, &icon_id, &role_id, &user.id).await?);
}
let partial = PartialRole {
name,
colour,
hoist,
icon: final_icon,
..Default::default()
};
@@ -69,8 +82,9 @@ pub async fn edit(
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?;
};
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id))
.await?;
}
Ok(Json(role.into()))
} else {

View File

@@ -1,6 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User,
util::{acker, permissions::DatabasePermissionQuery, reference::Reference},
Database, User, AMQP,
};
use revolt_permissions::PermissionQuery;
use revolt_result::{create_error, Result};
@@ -12,7 +12,12 @@ use rocket_empty::EmptyResponse;
/// Mark all channels in a server as read.
#[openapi(tag = "Server Information")]
#[put("/<target>/ack")]
pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Result<EmptyResponse> {
pub async fn ack(
db: &State<Database>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(create_error!(IsBot));
}
@@ -23,7 +28,6 @@ pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Res
return Err(create_error!(NotFound));
}
db.acknowledge_channels(&user.id, &server.channels)
.await
.map(|_| EmptyResponse)
acker::ack_server(&user, &server, db, amqp).await?;
Ok(EmptyResponse)
}

View File

@@ -1,14 +1,13 @@
use std::collections::HashSet;
use authifier::models::{totp::Totp, Account, ValidatedTicket};
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, File, PartialServer, User,
Database, File, PartialServer, User, ValidatedTicket,
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, Request, State};
use rocket::{serde::json::Json, State};
use validator::Validate;
/// # Edit Server
@@ -18,7 +17,6 @@ use validator::Validate;
#[patch("/<target>", data = "<data>")]
pub async fn edit(
db: &State<Database>,
account: Account,
user: User,
target: Reference<'_>,
data: Json<v0::DataEditServer>,

View File

@@ -0,0 +1,67 @@
//! Edit a session
//! PATCH /session/:id
use revolt_database::{Database, Session};
use revolt_models::v0;
use revolt_result::{Result, create_error};
use rocket::serde::json::Json;
use rocket::State;
/// # Edit Session
///
/// Edit current session information.
#[openapi(tag = "Session")]
#[patch("/<id>", data = "<data>")]
pub async fn edit(
db: &State<Database>,
user: Session,
id: String,
data: Json<v0::DataEditSession>,
) -> Result<Json<v0::SessionInfo>> {
let mut session = db.fetch_session(&id).await?;
// Make sure we own this session
if user.user_id != session.user_id {
return Err(create_error!(InvalidSession));
}
// Rename the session
session.name = data.into_inner().friendly_name;
// Save session
session.save(db).await?;
Ok(Json(session.into()))
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use rocket::http::{ContentType, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
use rocket::http::Header;
let harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness.client
.patch(format!("/auth/session/{}", session.id))
.header(ContentType::JSON)
.header(Header::new("X-Session-Token", session.token))
.body(
json!({
"friendly_name": "test name"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(res.into_json::<v0::SessionInfo>().await.unwrap().name, "test name");
}
}

View File

@@ -0,0 +1,52 @@
//! Fetch all sessions
//! GET /session/all
use revolt_result::Result;
use revolt_database::{Database, Session};
use revolt_models::v0;
use rocket::serde::json::Json;
use rocket::State;
/// # Fetch Sessions
///
/// Fetch all sessions associated with this account.
#[openapi(tag = "Session")]
#[get("/all")]
pub async fn fetch_all(
db: &State<Database>,
session: Session,
) -> Result<Json<Vec<v0::SessionInfo>>> {
db
.fetch_sessions(&session.user_id)
.await
.map(|ok| ok.into_iter().map(|session| session.into()).collect())
.map(Json)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use rocket::http::{Header, Status};
use revolt_models::v0;
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
for i in 1..=3 {
account
.create_session(&harness.db, format!("session{}", i))
.await
.unwrap();
}
let res = harness.client
.get("/auth/session/all")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert_eq!(res.into_json::<Vec<v0::SessionInfo>>().await.unwrap().len(), 4);
}
}

View File

@@ -0,0 +1,588 @@
//! Login to an account
//! POST /session/login
use std::ops::Add;
use std::time::Duration;
use tokio::time::sleep;
use iso8601_timestamp::Timestamp;
use revolt_database::{
util::{email::normalise_email, password::assert_safe},
Database, EmailVerification, Lockout, MFATicket,
};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Login
///
/// Login to an account.
#[openapi(tag = "Session")]
#[post("/login", data = "<data>")]
pub async fn login(
db: &State<Database>,
data: Json<v0::DataLogin>,
) -> Result<Json<v0::ResponseLogin>> {
// Random jitter from 0-1000ms
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
let (account, name) = match data.into_inner() {
v0::DataLogin::Email {
email,
password,
friendly_name,
} => {
// Try to find the account we want
let email_normalised = normalise_email(email);
// Lookup the email in database
if let Some(mut account) = db
.fetch_account_by_normalised_email(&email_normalised)
.await?
{
// Make sure the account has been verified
if let EmailVerification::Pending { .. } = account.verification {
return Err(create_error!(UnverifiedAccount));
}
// Make sure password has not been compromised
assert_safe(&password).await?;
// Check for account lockout
if let Some(lockout) = &account.lockout {
if let Some(expiry) = lockout.expiry {
if expiry > Timestamp::now_utc() {
return Err(create_error!(LockedOut));
}
}
}
// Verify the password is correct.
if let Err(err) = account.verify_password(&password) {
// Lock out account if attempts are too high
if let Some(lockout) = &mut account.lockout {
lockout.attempts += 1;
// Allow 3 attempts
//
// Lockout for 1 minute on 3rd attempt
// Lockout for 5 minutes on 4th attempt
// Lockout for 1 hour on each subsequent attempt
if lockout.attempts >= 3 {
lockout.expiry = Some(Timestamp::now_utc().add(Duration::from_secs(
if lockout.attempts >= 5 {
3600
} else if lockout.attempts == 4 {
300
} else {
60
},
)));
}
} else {
account.lockout = Some(Lockout {
attempts: 1,
expiry: None,
});
}
account.save(db).await?;
return Err(err);
}
// Clear lockout information if present
if account.lockout.is_some() {
account.lockout = None;
account.save(db).await?;
}
// Check whether an MFA step is required
if account.mfa.is_active() {
// Create a new ticket
let mut ticket = MFATicket::new(account.id, false);
ticket.populate(&account.mfa).await;
ticket.save(db).await?;
// Return applicable methods
return Ok(Json(v0::ResponseLogin::MFA {
ticket: ticket.token,
allowed_methods: account
.mfa
.get_methods()
.into_iter()
.map(Into::into)
.collect(),
}));
}
(account, friendly_name)
} else {
return Err(create_error!(InvalidCredentials));
}
}
v0::DataLogin::MFA {
mfa_ticket,
mfa_response,
friendly_name,
} => {
// Resolve the MFA ticket
let ticket = db
.fetch_ticket_by_token(&mfa_ticket)
.await?;
// Find the corresponding account
let mut account = db.fetch_account(&ticket.account_id).await?;
// Verify the MFA response
if let Some(mfa_response) = mfa_response {
account
.consume_mfa_response(db, mfa_response, Some(ticket))
.await?;
} else if !ticket.authorised {
return Err(create_error!(InvalidToken));
}
(account, friendly_name)
}
};
// Generate a session name
let name = name.unwrap_or_else(|| "Unknown".to_string());
// Prevent disabled accounts from logging in
if account.disabled {
return Ok(Json(v0::ResponseLogin::Disabled {
user_id: account.id,
}));
}
// Create and return a new session
Ok(Json(v0::ResponseLogin::Success(
account.create_session(db, name).await?.into(),
)))
}
#[cfg(test)]
mod tests {
use iso8601_timestamp::Timestamp;
use revolt_database::{Account, EmailVerification, Lockout, MFATicket, Totp, events::client::EventV1};
use crate::{rocket, util::test::TestHarness};
use rocket::http::{ContentType, Status};
use revolt_models::v0;
use revolt_result::{Error, ErrorType};
#[rocket::async_test]
async fn success() {
let mut harness = TestHarness::new().await;
Account::new(
&harness.db,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
harness.wait_for_event("global", |_| true).await;
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "EXAMPLE@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(res.into_json::<v0::Session>().await.is_some());
let event = harness.wait_for_event("global", |_| true).await;
if !matches!(event, EventV1::CreateSession { .. }) {
panic!("Received incorrect event type. {:?}", event);
}
}
#[rocket::async_test]
async fn success_totp_mfa() {
let harness = TestHarness::new().await;
let (mut account, _, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": &account.email,
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = serde_json::from_str::<v0::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
if let v0::ResponseLogin::MFA {
ticket,
allowed_methods,
} = response
{
assert!(allowed_methods.contains(&v0::MFAMethod::Totp));
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"mfa_ticket": ticket,
"mfa_response": {
"totp_code": totp.generate_code().expect("totp code")
}
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
} else {
panic!("expected `ResponseLogin::MFA`")
}
}
#[rocket::async_test]
async fn success_totp_stored_mfa() {
let harness = TestHarness::new().await;
let (mut account, _, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let mut ticket = MFATicket::new(account.id.to_string(), true);
ticket.last_totp_code = Some("token from earlier".into());
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"mfa_ticket": ticket.token,
"mfa_response": {
"totp_code": "token from earlier"
}
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
}
#[rocket::async_test]
async fn fail_totp_invalid_mfa() {
let harness = TestHarness::new().await;
let (mut account, _, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/session/login")
.json(
&json!({
"email": account.email.clone(),
"password": "password_insecure"
})
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = serde_json::from_str::<v0::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
if let v0::ResponseLogin::MFA {
ticket,
allowed_methods,
} = response
{
assert!(allowed_methods.contains(&v0::MFAMethod::Totp));
let res = harness.client
.post("/auth/session/login")
.json(
&json!({
"mfa_ticket": ticket,
"mfa_response": {
"totp_code": "some random data"
}
})
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidToken,
));
} else {
panic!("expected `ResponseLogin::MFA`")
}
}
#[rocket::async_test]
async fn fail_invalid_user() {
let harness = TestHarness::new().await;
let res = harness.client
.post("/auth/session/login")
.json(
&json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidCredentials,
));
}
#[rocket::async_test]
async fn fail_disabled_account() {
let harness = TestHarness::new().await;
let mut account = Account::new(
&harness.db,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.disabled = true;
account.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = serde_json::from_str::<v0::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
assert!(matches!(
response,
v0::ResponseLogin::Disabled { .. }
));
}
#[rocket::async_test]
async fn fail_unverified_account() {
let harness = TestHarness::new().await;
let mut account = Account::new(
&harness.db,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.verification = EmailVerification::Pending {
token: "".to_string(),
expiry: Timestamp::now_utc(),
};
account.save(&harness.db).await.unwrap();
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Forbidden);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::UnverifiedAccount,
));
}
#[rocket::async_test]
async fn fail_locked_account() {
let harness = TestHarness::new().await;
let mut account = Account::new(
&harness.db,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.save(&harness.db).await.unwrap();
// Attempt 1
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidCredentials,
));
// Attempt 2
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidCredentials,
));
// Attempt 3
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::InvalidCredentials,
));
// Attempt 4: Locked Out
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Forbidden);
assert!(matches!(
res.into_json::<Error>().await.unwrap().error_type,
ErrorType::LockedOut,
));
// Pretend it expired
account.lockout = Some(Lockout {
attempts: 9001,
expiry: Some(Timestamp::now_utc()),
});
account.save(&harness.db).await.unwrap();
// Once it expires, we can log in.
let res = harness.client
.post("/auth/session/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
}
}

View File

@@ -0,0 +1,79 @@
//! Logout of current session
//! POST /session/logout
use revolt_database::{Database, Session};
use revolt_result::Result;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Logout
///
/// Delete current session.
#[openapi(tag = "Session")]
#[post("/logout")]
pub async fn logout(db: &State<Database>, session: Session) -> Result<EmptyResponse> {
session.delete(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::events::client::EventV1;
use revolt_result::ErrorType;
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let mut harness = TestHarness::new().await;
let (_, session, _) = harness.new_user().await;
let res = harness.client
.post("/auth/session/logout")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
drop(res);
assert!(matches!(
harness.db
.fetch_session(&session.id)
.await
.unwrap_err().error_type,
ErrorType::UnknownUser
));
let event = harness.wait_for_event(&format!("{}!", &session.user_id), |evt| matches!(evt, EventV1::DeleteSession { .. })).await;
if let EventV1::DeleteSession {
user_id,
session_id,
} = event
{
assert_eq!(user_id, session.user_id);
assert_eq!(session_id, session.id);
} else {
panic!("Received incorrect event type. {:?}", event);
}
}
#[rocket::async_test]
async fn fail_invalid_session() {
let harness = TestHarness::new().await;
let res = harness.client
.post("/auth/session/logout")
.header(Header::new("X-Session-Token", "invalid"))
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
#[rocket::async_test]
async fn fail_no_session() {
let harness = TestHarness::new().await;
let res = harness.client.post("/auth/session/logout").dispatch().await;
assert_eq!(res.status(), Status::Unauthorized);
}
}

View File

@@ -0,0 +1,20 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
pub mod edit;
pub mod fetch_all;
pub mod login;
pub mod logout;
pub mod revoke;
pub mod revoke_all;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
login::login,
logout::logout,
fetch_all::fetch_all,
revoke::revoke,
revoke_all::revoke_all,
edit::edit
]
}

View File

@@ -0,0 +1,114 @@
//! Revoke an active session
//! DELETE /session/:id
use revolt_database::{Database, Session, ValidatedTicket};
use revolt_result::{Result, create_error};
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Revoke Session
///
/// Delete a specific active session.
#[openapi(tag = "Session")]
#[delete("/<id>")]
pub async fn revoke(
db: &State<Database>,
_ticket: ValidatedTicket,
user: Session,
id: String,
) -> Result<EmptyResponse> {
let session = db.fetch_session(&id).await?;
if session.user_id != user.user_id {
return Err(create_error!(InvalidToken));
}
session.delete(db).await.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, Totp};
use revolt_result::ErrorType;
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete(format!("/auth/session/{}", session.id))
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
assert!(matches!(
harness.db
.fetch_session(&session.id)
.await
.unwrap_err()
.error_type,
ErrorType::UnknownUser
));
}
#[rocket::async_test]
async fn success_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete(format!("/auth/session/{}", session.id))
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
assert!(matches!(
harness.db
.fetch_session(&session.id)
.await
.unwrap_err()
.error_type,
ErrorType::UnknownUser
));
}
#[rocket::async_test]
async fn fail_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness.client
.delete(format!("/auth/session/{}", session.id))
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
}

View File

@@ -0,0 +1,196 @@
//! Revoke all sessions
//! DELETE /session/all
use revolt_database::{Account, Database, Session, ValidatedTicket};
use revolt_result::Result;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Delete All Sessions
///
/// Delete all active sessions, optionally including current one.
#[openapi(tag = "Session")]
#[delete("/all?<revoke_self>")]
pub async fn revoke_all(
db: &State<Database>,
_ticket: ValidatedTicket,
session: Session,
account: Account,
revoke_self: Option<bool>,
) -> Result<EmptyResponse> {
let ignore = if revoke_self.unwrap_or(false) {
None
} else {
Some(session.id)
};
account
.delete_all_sessions(db, ignore)
.await
.map(|_| EmptyResponse)
}
#[cfg(test)]
mod tests {
use crate::{rocket, util::test::TestHarness};
use revolt_database::{MFATicket, Totp};
use rocket::http::{Header, Status};
#[rocket::async_test]
async fn success() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
for i in 1..=3 {
account
.create_session(&harness.db, format!("session{}", i))
.await
.unwrap();
}
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/session/all?revoke_self=true")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
assert!(harness.db
.fetch_sessions(&session.user_id)
.await
.unwrap()
.is_empty());
}
#[rocket::async_test]
async fn success_not_including_self() {
let harness = TestHarness::new().await;
let (account, session, _) = harness.new_user().await;
for i in 1..=3 {
account
.create_session(&harness.db, format!("session{}", i))
.await
.unwrap();
}
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/session/all?revoke_self=false")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let sessions = harness.db
.fetch_sessions(&session.user_id)
.await
.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, session.id);
}
#[rocket::async_test]
async fn success_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
for i in 1..=3 {
account
.create_session(&harness.db, format!("session{}", i))
.await
.unwrap();
}
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/session/all?revoke_self=true")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
assert!(harness.db
.fetch_sessions(&session.user_id)
.await
.unwrap()
.is_empty());
}
#[rocket::async_test]
async fn success_not_including_self_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
for i in 1..=3 {
account
.create_session(&harness.db, format!("session{}", i))
.await
.unwrap();
}
let ticket = MFATicket::new(account.id.to_string(), true);
ticket.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/session/all?revoke_self=false")
.header(Header::new("X-Session-Token", session.token))
.header(Header::new("X-MFA-Ticket", ticket.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::NoContent);
let sessions = harness.db
.fetch_sessions(&session.user_id)
.await
.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, session.id);
}
#[rocket::async_test]
async fn fail_mfa() {
let harness = TestHarness::new().await;
let (mut account, session, _) = harness.new_user().await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&harness.db).await.unwrap();
let res = harness.client
.delete("/auth/session/all")
.header(Header::new("X-Session-Token", session.token))
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
}
}

View File

@@ -1,7 +1,6 @@
use authifier::models::Account;
use once_cell::sync::Lazy;
use regex::Regex;
use revolt_database::{Database, User};
use revolt_database::{Database, User, Account};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};

View File

@@ -48,6 +48,7 @@ pub async fn edit(
// Exit out early if nothing is changed
if data.display_name.is_none()
&& data.pronouns.is_none()
&& data.status.is_none()
&& data.profile.is_none()
&& data.avatar.is_none()
@@ -80,6 +81,7 @@ pub async fn edit(
let mut partial: PartialUser = PartialUser {
display_name: data.display_name,
pronouns: data.pronouns,
badges: data.badges,
flags: data.flags,
..Default::default()

View File

@@ -1,19 +1,23 @@
use rocket::Route;
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod webhook_delete;
mod webhook_delete_message;
mod webhook_delete_token;
mod webhook_edit;
mod webhook_edit_message;
mod webhook_edit_token;
mod webhook_execute;
mod webhook_fetch_token;
mod webhook_fetch;
mod webhook_execute_github;
mod webhook_fetch;
mod webhook_fetch_token;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
webhook_delete_message::webhook_delete_message,
webhook_delete_token::webhook_delete_token,
webhook_delete::webhook_delete,
webhook_edit_message::webhook_edit_message,
webhook_edit_token::webhook_edit_token,
webhook_edit::webhook_edit,
webhook_execute_github::webhook_execute_github,

View File

@@ -0,0 +1,27 @@
use revolt_database::{util::reference::Reference, Database};
use revolt_result::{create_error, Result};
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Deletes a webhook message
///
/// Deletes a message sent by a webhook
#[openapi(tag = "Webhooks")]
#[delete("/<webhook_id>/<token>/<message_id>")]
pub async fn webhook_delete_message(
db: &State<Database>,
webhook_id: Reference<'_>,
token: String,
message_id: Reference<'_>,
) -> Result<EmptyResponse> {
let webhook = webhook_id.as_webhook(db).await?;
webhook.assert_token(&token)?;
let message = message_id.as_message(db).await?;
if message.author != webhook.id {
return Err(create_error!(CannotDeleteMessage));
}
message.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,84 @@
use iso8601_timestamp::Timestamp;
use revolt_config::config;
use revolt_database::{
tasks::process_embeds::queue, util::reference::Reference, Database, Message, PartialMessage,
};
use revolt_models::v0::{self, DataEditMessage, Embed};
use revolt_models::validator::Validate;
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
/// # Edits a webhook message
///
/// Edits a message sent by a webhook
#[openapi(tag = "Webhooks")]
#[patch("/<webhook_id>/<token>/<message_id>", data = "<data>")]
pub async fn webhook_edit_message(
db: &State<Database>,
webhook_id: Reference<'_>,
token: String,
message_id: Reference<'_>,
data: Json<DataEditMessage>,
) -> Result<Json<v0::Message>> {
let edit = data.into_inner();
edit.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
Message::validate_sum(
&edit.content,
edit.embeds.as_deref().unwrap_or_default(),
config().await.features.limits.default.message_length,
)?;
let webhook = webhook_id.as_webhook(db).await?;
webhook.assert_token(&token)?;
let mut message = message_id.as_message(db).await?;
if message.author != webhook.id {
return Err(create_error!(CannotEditMessage));
}
message.edited = Some(Timestamp::now_utc());
let mut partial = PartialMessage {
edited: message.edited,
..Default::default()
};
// 1. Handle content update
if let Some(content) = &edit.content {
partial.content = Some(content.clone());
}
// 2. Clear any auto generated embeds
let mut new_embeds = vec![];
if let Some(embeds) = &message.embeds {
for embed in embeds {
if let Embed::Text(embed) = embed {
new_embeds.push(Embed::Text(embed.clone()))
}
}
}
// 3. Replace if we are given new embeds
if let Some(embeds) = edit.embeds {
new_embeds.clear();
for embed in embeds {
new_embeds.push(message.create_embed(db, embed).await?);
}
}
partial.embeds = Some(new_embeds);
message.update(db, partial, vec![]).await?;
// Queue up a task for processing embeds
if let Some(content) = edit.content {
queue(message.channel.to_string(), message.id.to_string(), content).await;
}
Ok(Json(message.into_model(None, None)))
}

View File

@@ -1,2 +1,4 @@
pub mod ratelimits;
#[cfg(test)]
pub mod test;

View File

@@ -1,22 +1,24 @@
use authifier::{
models::{Account, EmailVerification, Session},
Authifier,
};
use std::time::Duration;
use futures::StreamExt;
use rand::Rng;
use redis_kiss::redis::aio::PubSub;
use revolt_database::util::email::normalise_email;
use revolt_database::util::password::hash_password;
use revolt_database::{
events::client::EventV1, Channel, Database, Member, Message, PartialRole, Server, User, AMQP,
};
use revolt_database::{util::idempotency::IdempotencyKey, Role};
use revolt_database::{Account, EmailVerification, Session};
use revolt_models::v0;
use revolt_permissions::OverrideField;
use rocket::http::Header;
use rocket::local::asynchronous::{Client, LocalRequest, LocalResponse};
use rocket::tokio;
use serde::{Deserialize, Serialize};
pub struct TestHarness {
pub client: Client,
authifier: Authifier,
pub db: Database,
pub amqp: AMQP,
sub: PubSub,
@@ -25,8 +27,6 @@ pub struct TestHarness {
impl TestHarness {
pub async fn new() -> TestHarness {
let config = revolt_config::config().await;
let client = Client::tracked(crate::web().await)
.await
.expect("valid rocket instance");
@@ -43,29 +43,10 @@ impl TestHarness {
.expect("`Database`")
.clone();
let authifier = client
.rocket()
.state::<Authifier>()
.expect("`Authifier`")
.clone();
let connection = amqprs::connection::Connection::open(
&amqprs::connection::OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
),
)
.await
.unwrap();
let channel = connection.open_channel(None).await.unwrap();
let amqp = AMQP::new(connection, channel);
let amqp = AMQP::new_auto().await;
TestHarness {
client,
authifier,
db,
amqp,
sub,
@@ -93,11 +74,12 @@ impl TestHarness {
}
pub async fn account_from_user(&self, id: String) -> (Account, Session) {
let email = format!("{}@stoat.chat", TestHarness::rand_string());
let account = Account {
id,
email: format!("{}@revolt.chat", TestHarness::rand_string()),
password: Default::default(),
email_normalised: Default::default(),
email: email.clone(),
password: hash_password("password_insecure".to_string()).unwrap(),
email_normalised: normalise_email(email),
deletion: None,
disabled: false,
lockout: None,
@@ -106,14 +88,10 @@ impl TestHarness {
verification: EmailVerification::Verified,
};
self.authifier
.database
.save_account(&account)
.await
.expect("`Account`");
self.db.save_account(&account).await.expect("`Account`");
let session = account
.create_session(&self.authifier, String::new())
.create_session(&self.db, String::new())
.await
.expect("`Session`");
@@ -251,6 +229,40 @@ impl TestHarness {
unreachable!()
}
pub async fn assert_email(&self, mailbox: &str) -> (Mail, String) {
// Wait a moment for maildev to catch the email
tokio::time::sleep(Duration::from_secs(1)).await;
let client = reqwest::Client::new();
let results = client
.get("http://localhost:14080/email")
.send()
.await
.unwrap()
.json::<Vec<Mail>>()
.await
.unwrap();
let re = regex::Regex::new(r"\[\[([A-Za-z0-9_-]*)\]\]").unwrap();
for entry in results.into_iter().rev() {
if entry.envelope.to[0].address == mailbox {
client
.delete(format!("http://localhost:14080/delete/{}", &entry.id))
.send()
.await
.unwrap();
let code = re.captures_iter(&entry.text).next().unwrap()[1].to_string();
return (entry, code);
}
}
panic!("Email not found.")
}
pub async fn wait_for_message(&mut self, channel_id: &str) -> v0::Message {
dbg!(&self.event_buffer);
@@ -266,3 +278,22 @@ impl TestHarness {
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Mail {
pub id: String,
pub envelope: MailEnvelope,
pub subject: String,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MailEnvelope {
pub from: MailAddress,
pub to: Vec<MailAddress>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MailAddress {
pub address: String,
}