Merge remote-tracking branch 'origin/main' into feat/audit-log

Signed-off-by: Zomatree <me@zomatree.live>
This commit is contained in:
Zomatree
2026-06-23 15:31:44 +01:00
225 changed files with 116692 additions and 4772 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-coalesced"
version = "0.12.1"
version = "0.13.7"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-config"
version = "0.12.1"
version = "0.13.7"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -14,13 +14,12 @@ anyhow = ["dep:sentry-anyhow"]
report-macros = ["revolt-result"]
sentry = ["dep:sentry"]
test = ["async-std"]
default = ["test", "sentry"]
default = ["sentry"]
[dependencies]
# Utility
config = { workspace = true }
cached = { workspace = true }
once_cell = { workspace = true }
# Serde
serde = { workspace = true }

View File

@@ -1,3 +1,5 @@
environment = "test"
[database]
mongodb = "mongodb://localhost"
redis = "redis://localhost/"
@@ -10,3 +12,12 @@ password = "rabbitpass"
[features]
webhooks_enabled = true
[api.smtp]
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@stoat.chat"
reply_to = "support@stoat.chat"
port = 14025
use_tls = false

View File

@@ -1,5 +1,6 @@
production = false
disable_events_dont_use = false
environment = "dev"
[database]
# MongoDB connection URL
@@ -30,6 +31,10 @@ host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api]
@@ -49,6 +54,10 @@ from_address = "noreply@example.com"
# port = 587
# use_tls = true
[api.smtp.expiry]
expire_verification = 604800 # 3600 * 24 * 7
expire_password_reset = 86400 # 3600 * 24
expire_account_deletion = 86400 # 3600 * 24
[api.security]
# Authifier Shield API key
@@ -67,6 +76,10 @@ tenor_key = ""
hcaptcha_key = ""
hcaptcha_sitekey = ""
[api.security.shield]
host = ""
key = ""
[api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50
@@ -82,6 +95,8 @@ expires_after = 2592000 # 30d
[api.livekit.nodes]
[api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd]
# this changes the names of the queues to not overlap
@@ -134,6 +149,8 @@ pkcs8 = ""
key_id = ""
team_id = ""
[january]
blocked_domains = []
[files]
# Encryption key for stored files
@@ -317,6 +334,12 @@ emojis = 500_000
# default: 5
process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry]
# Configuration for Sentry error reporting
api = ""

View File

@@ -1,9 +1,10 @@
use std::{collections::HashMap, path::Path};
#[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize;
#[cfg(feature = "sentry")]
@@ -66,13 +67,28 @@ static CONFIG_SEARCH_PATHS: [&str; 3] = [
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder
static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"),
FileFormat::Toml,
));
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"),
@@ -94,21 +110,6 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
}
}
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
@@ -122,12 +123,19 @@ pub struct Database {
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Rabbit {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
@@ -155,6 +163,18 @@ pub struct ApiSmtp {
pub port: Option<i32>,
pub use_tls: Option<bool>,
pub use_starttls: Option<bool>,
pub expiry: EmailExpiry,
}
/// Email expiration config
#[derive(Deserialize, Debug, Clone)]
pub struct EmailExpiry {
/// How long email verification codes should last for (in seconds)
pub expire_verification: i64,
/// How long password reset codes should last for (in seconds)
pub expire_password_reset: i64,
/// How long account deletion codes should last for (in seconds)
pub expire_account_deletion: i64,
}
#[derive(Deserialize, Debug, Clone)]
@@ -194,9 +214,15 @@ pub struct ApiSecurityCaptcha {
pub hcaptcha_sitekey: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity {
pub authifier_shield_key: String,
pub shield: ApiSecurityShield,
pub voso_legacy_token: String,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
@@ -231,6 +257,7 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
@@ -308,6 +335,11 @@ impl Pushd {
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
@@ -383,6 +415,16 @@ pub struct FeaturesLimitsCollection {
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
@@ -400,6 +442,7 @@ impl Default for FeaturesAdvanced {
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
@@ -427,10 +470,12 @@ pub struct Settings {
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool,
}
@@ -457,8 +502,7 @@ pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone()
}
#[cached(time = 30)]
pub async fn config() -> Settings {
pub async fn config_no_cache() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
@@ -476,6 +520,34 @@ pub async fn config() -> Settings {
config
}
#[cached(time = 30)]
pub async fn config() -> Settings {
#[cfg(feature = "test")]
if let Some(overwrites) = CONFIG_OVERWRITES.get() {
return overwrites.clone();
}
config_no_cache().await
}
#[cfg(feature = "test")]
static CONFIG_OVERWRITES: OnceLock<Settings> = OnceLock::new();
/// Modify the config values for a test, this can only be called once
///
/// This will also fail if two or more tests are running in the same process and both try to modify the config,
/// This could happen if tests where run under `cargo test` instead of `nextest`.
#[cfg(feature = "test")]
pub async fn overwrite_config(f: impl FnOnce(&mut Settings)) {
let mut config = config_no_cache().await;
f(&mut config);
CONFIG_OVERWRITES.set(config).expect(
"Cannot overwrite config multiple times, make sure you are running tests through nextest.",
);
}
/// Configure logging and common Rust variables
#[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-database"
version = "0.12.1"
version = "0.13.7"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -11,19 +11,18 @@ repository = "https://github.com/stoatchat/stoatchat"
[features]
# Databases
mongodb = ["dep:mongodb", "bson", "authifier/database-mongodb"]
mongodb = ["dep:mongodb", "bson"]
# ... Other
tasks = ["isahc", "linkify", "url-escape"]
async-std-runtime = ["async-std", "authifier/async-std-runtime"]
async-std-runtime = ["async-std"]
rocket-impl = [
"rocket",
"schemars",
"revolt_okapi",
"revolt_rocket_okapi",
"authifier/rocket_impl",
]
axum-impl = ["axum", "revolt-result/axum"]
axum-impl = ["axum", "revolt-result/axum", "utoipa"]
redis-is-patched = ["revolt-presence/redis-is-patched"]
voice = ["livekit-api", "livekit-protocol", "livekit-runtime"]
@@ -38,6 +37,7 @@ revolt-models = { workspace = true, features = ["validator"] }
revolt-presence = { workspace = true }
revolt-permissions = { workspace = true, features = ["serde", "bson"] }
revolt-parser = { workspace = true }
revolt-coalesced = { workspace = true }
# Utility
log = { workspace = true }
@@ -54,6 +54,8 @@ linkify = { workspace = true, optional = true }
url-escape = { workspace = true, optional = true }
validator = { workspace = true, features = ["derive"] }
isahc = { workspace = true, features = ["json"], optional = true }
base32 = { workspace = true }
sha1 = { workspace = true }
# Serialisation
serde_json = { workspace = true }
@@ -83,6 +85,7 @@ async-std = { workspace = true, features = ["attributes"], optional = true }
# Axum Impl
axum = { workspace = true, optional = true }
utoipa = { workspace = true, features = ["axum_extras"], optional = true }
# Rocket Impl
schemars = { workspace = true, optional = true }
@@ -90,13 +93,21 @@ rocket = { workspace = true, features = ["json"], optional = true }
revolt_okapi = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
# Authifier
authifier = { workspace = true }
# RabbitMQ
amqprs = { workspace = true }
lapin = { workspace = true, features = ["tokio"] }
# Voice
livekit-api = { workspace = true, optional = true }
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"], optional = true }
livekit-protocol = { workspace = true, optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true }
# Security
totp-lite = { workspace = true }
rust-argon2 = { workspace = true }
# Email
lettre = { workspace = true }
handlebars = { workspace = true }
# Web Requests
reqwest = { workspace = true, features = ["json", "form"] }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,555 @@
this is an assorted list of known disposable email providers
#region nobody will ever have an email @example.com so we can safely block it
----
example.com
#region list provided by michenriksen at https://gist.github.com/michenriksen/8710649
----
0815.ru
0wnd.net
0wnd.org
10minutemail.co.za
10minutemail.com
123-m.com
1fsdfdsfsdf.tk
1pad.de
20minutemail.com
21cn.com
2fdgdfgdfgdf.tk
2prong.com
30minutemail.com
33mail.com
3trtretgfrfe.tk
4gfdsgfdgfd.tk
4warding.com
5ghgfhfghfgh.tk
6hjgjhgkilkj.tk
6paq.com
7tags.com
9ox.net
a-bc.net
agedmail.com
ama-trade.de
amilegit.com
amiri.net
amiriindustries.com
anonmails.de
anonymbox.com
antichef.com
antichef.net
antireg.ru
antispam.de
antispammail.de
armyspy.com
artman-conception.com
azmeil.tk
baxomale.ht.cx
beefmilk.com
bigstring.com
binkmail.com
bio-muesli.net
bobmail.info
bodhi.lawlita.com
bofthew.com
bootybay.de
boun.cr
bouncr.com
breakthru.com
brefmail.com
bsnow.net
bspamfree.org
bugmenot.com
bund.us
burstmail.info
buymoreplays.com
byom.de
c2.hu
casualdx.com
cek.pm
centermail.com
centermail.net
chammy.info
childsavetrust.org
chogmail.com
choicemail1.com
clixser.com
cmail.net
cmail.org
coldemail.info
cool.fr.nf
courriel.fr.nf
courrieltemporaire.com
crapmail.org
cust.in
cuvox.de
d3p.dk
dacoolest.com
dandikmail.com
dayrep.com
dcemail.com
deadaddress.com
deadspam.com
delikkt.de
despam.it
despammed.com
devnullmail.com
dfgh.net
digitalsanctuary.com
dingbone.com
disposableaddress.com
disposableemailaddresses.com
disposableinbox.com
dispose.it
dispostable.com
dodgeit.com
dodgit.com
donemail.ru
dontreg.com
dontsendmespam.de
drdrb.net
dump-email.info
dumpandjunk.com
dumpyemail.com
e-mail.com
e-mail.org
e4ward.com
easytrashmail.com
einmalmail.de
einrot.com
eintagsmail.de
emailgo.de
emailias.com
emaillime.com
emailsensei.com
emailtemporanea.com
emailtemporanea.net
emailtemporar.ro
emailtemporario.com.br
emailthe.net
emailtmp.com
emailwarden.com
emailx.at.hm
emailxfer.com
emeil.in
emeil.ir
emz.net
ero-tube.org
evopo.com
explodemail.com
eyepaste.com
fakeinbox.com
fakeinformation.com
fansworldwide.de
fantasymail.de
fightallspam.com
filzmail.com
fivemail.de
fleckens.hu
frapmail.com
friendlymail.co.uk
fuckingduh.com
fudgerub.com
fyii.de
garliclife.com
gehensiemirnichtaufdensack.de
get2mail.fr
getairmail.com
getmails.eu
getonemail.com
giantmail.de
girlsundertheinfluence.com
gishpuppy.com
gmial.com
goemailgo.com
gotmail.net
gotmail.org
gotti.otherinbox.com
great-host.in
greensloth.com
grr.la
gsrv.co.uk
guerillamail.biz
guerillamail.com
guerrillamail.biz
guerrillamail.com
guerrillamail.de
guerrillamail.info
guerrillamail.net
guerrillamail.org
guerrillamailblock.com
gustr.com
harakirimail.com
hat-geld.de
hatespam.org
herp.in
hidemail.de
hidzz.com
hmamail.com
hopemail.biz
ieh-mail.de
ikbenspamvrij.nl
imails.info
inbax.tk
inbox.si
inboxalias.com
inboxclean.com
inboxclean.org
instant-mail.de
ip6.li
irish2me.com
iwi.net
jetable.com
jetable.fr.nf
jetable.net
jetable.org
jnxjn.com
jourrapide.com
jsrsolutions.com
kasmail.com
kaspop.com
killmail.com
killmail.net
klassmaster.com
klzlk.com
koszmail.pl
kurzepost.de
lawlita.com
letthemeatspam.com
lhsdv.com
lifebyfood.com
link2mail.net
litedrop.com
lol.ovpn.to
lolfreak.net
lookugly.com
lortemail.dk
lr78.com
lroid.com
lukop.dk
m21.cc
mail-filter.com
mail-temporaire.fr
mail.mezimages.net
mail1a.de
mail21.cc
mail2rss.org
mail333.com
mailbidon.com
mailbiz.biz
mailblocks.com
mailbucket.org
mailcat.biz
mailcatch.com
mailde.de
mailde.info
maildrop.cc
maileimer.de
mailexpire.com
mailfa.tk
mailforspam.com
mailfreeonline.com
mailguard.me
mailin8r.com
mailinater.com
mailinator.com
mailinator.net
mailinator.org
mailinator2.com
mailincubator.com
mailismagic.com
mailme.lv
mailme24.com
mailmetrash.com
mailmoat.com
mailms.com
mailnesia.com
mailnull.com
mailorg.org
mailpick.biz
mailrock.biz
mailscrap.com
mailshell.com
mailsiphon.com
mailtemp.info
mailtome.de
mailtothis.com
mailtrash.net
mailtv.net
mailtv.tv
mailzilla.com
makemetheking.com
manybrain.com
mbx.cc
mega.zik.dj
meinspamschutz.de
meltmail.com
messagebeamer.de
mezimages.net
ministry-of-silly-walks.de
mintemail.com
misterpinball.de
moncourrier.fr.nf
monemail.fr.nf
monmail.fr.nf
monumentmail.com
mt2009.com
mt2014.com
mycleaninbox.net
mymail-in.net
mypacks.net
mypartyclip.de
myphantomemail.com
mysamp.de
mytempemail.com
mytempmail.com
mytrashmail.com
nabuma.com
neomailbox.com
nepwk.com
nervmich.net
nervtmich.net
netmails.com
netmails.net
neverbox.com
nice-4u.com
nincsmail.hu
nnh.com
no-spam.ws
noblepioneer.com
nomail.pw
nomail.xl.cx
nomail2me.com
nomorespamemails.com
nospam.ze.tc
nospam4.us
nospamfor.us
nospammail.net
notmailinator.com
nowhere.org
nowmymail.com
nurfuerspam.de
nus.edu.sg
objectmail.com
obobbo.com
odnorazovoe.ru
oneoffemail.com
onewaymail.com
onlatedotcom.info
online.ms
ordinaryamerican.net
otherinbox.com
ovpn.to
owlpic.com
pancakemail.com
pcusers.otherinbox.com
pjjkp.com
plexolan.de
poczta.onet.pl
politikerclub.de
poofy.org
pookmail.com
privacy.net
privatdemail.net
proxymail.eu
prtnx.com
putthisinyourspamdatabase.com
putthisinyourspamdatabase.com
quickinbox.com
rcpt.at
reallymymail.com
realtyalerts.ca
recode.me
recursor.net
reliable-mail.com
rhyta.com
rmqkr.net
royal.net
rtrtr.com
s0ny.net
safersignup.de
safetymail.info
safetypost.de
saynotospams.com
schafmail.de
schrott-email.de
secretemail.de
secure-mail.biz
senseless-entertainment.com
services391.com
sharklasers.com
shieldemail.com
shiftmail.com
shitmail.me
shitware.nl
shmeriously.com
shortmail.net
sinnlos-mail.de
slapsfromlastnight.com
slaskpost.se
smashmail.de
smellfear.com
snakemail.com
sneakemail.com
sneakmail.de
snkmail.com
sofimail.com
solvemail.info
sogetthis.com
soodonims.com
spam4.me
spamail.de
spamarrest.com
spambob.net
spambog.ru
spambox.us
spamcannon.com
spamcannon.net
spamcon.org
spamcorptastic.com
spamcowboy.com
spamcowboy.net
spamcowboy.org
spamday.com
spamex.com
spamfree.eu
spamfree24.com
spamfree24.de
spamfree24.org
spamgoes.in
spamgourmet.com
spamgourmet.net
spamgourmet.org
spamherelots.com
spamherelots.com
spamhereplease.com
spamhereplease.com
spamhole.com
spamify.com
spaml.de
spammotel.com
spamobox.com
spamslicer.com
spamspot.com
spamthis.co.uk
spamtroll.net
speed.1s.fr
spoofmail.de
stuffmail.de
super-auswahl.de
supergreatmail.com
supermailer.jp
superrito.com
superstachel.de
suremail.info
talkinator.com
teewars.org
teleworm.com
teleworm.us
temp-mail.org
temp-mail.ru
tempe-mail.com
tempemail.co.za
tempemail.com
tempemail.net
tempemail.net
tempinbox.co.uk
tempinbox.com
tempmail.eu
tempmaildemo.com
tempmailer.com
tempmailer.de
tempomail.fr
temporaryemail.net
temporaryforwarding.com
temporaryinbox.com
temporarymailaddress.com
tempthe.net
thankyou2010.com
thc.st
thelimestones.com
thisisnotmyrealemail.com
thismail.net
throwawayemailaddress.com
tilien.com
tittbit.in
tizi.com
tmailinator.com
toomail.biz
topranklist.de
tradermail.info
trash-mail.at
trash-mail.com
trash-mail.de
trash2009.com
trashdevil.com
trashemail.de
trashmail.at
trashmail.com
trashmail.de
trashmail.me
trashmail.net
trashmail.org
trashymail.com
trialmail.de
trillianpro.com
twinmail.de
tyldd.com
uggsrock.com
umail.net
uroid.com
us.af
venompen.com
veryrealemail.com
viditag.com
viralplays.com
vpn.st
vsimcard.com
vubby.com
wasteland.rfc822.org
webemail.me
weg-werf-email.de
wegwerf-emails.de
wegwerfadresse.de
wegwerfemail.com
wegwerfemail.de
wegwerfmail.de
wegwerfmail.info
wegwerfmail.net
wegwerfmail.org
wh4f.org
whyspam.me
willhackforfood.biz
willselfdestruct.com
winemaven.info
wronghead.com
www.e4ward.com
www.mailinator.com
wwwnew.eu
x.ip6.li
xagloo.com
xemaps.com
xents.com
xmaily.com
xoxy.net
yep.it
yogamaven.com
yopmail.com
yopmail.fr
yopmail.net
yourdomain.com
yuurok.com
z1p.biz
za.com
zehnminuten.de
zehnminutenmail.de
zippymail.info
zoemail.net
zomg.info
#region public emails provided by mail.tm
----
trythe.net
leadwizzer.com
metalunits.com
scpulse.com

View File

@@ -1,58 +1,77 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::events::rabbit::*;
use crate::User;
use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
use amqprs::connection::OpenConnectionArguments;
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
use amqprs::{BasicProperties, FieldTable};
use lapin::{
options::BasicPublishOptions,
protocol::basic::AMQPProperties,
types::{AMQPValue, FieldTable},
Channel, Connection, ConnectionProperties, Error as AMQPError,
};
use revolt_models::v0::PushNotification;
use revolt_presence::filter_online;
use revolt_result::Result;
use serde_json::to_string;
#[derive(Clone)]
pub struct AMQP {
friend_request_accepted: Arc<Channel>,
friend_request_received: Arc<Channel>,
generic_message: Arc<Channel>,
message_sent: Arc<Channel>,
mass_mention_message_sent: Arc<Channel>,
ack_notification_message: Arc<Channel>,
dm_call_updated: Arc<Channel>,
process_ack: Arc<Channel>,
#[allow(unused)]
connection: Connection,
channel: Channel,
connection: Arc<Connection>,
}
impl AMQP {
pub fn new(connection: Connection, channel: Channel) -> AMQP {
AMQP {
pub async fn new(connection: Arc<Connection>) -> Self {
Self {
friend_request_accepted: Self::create_channel(&connection).await,
friend_request_received: Self::create_channel(&connection).await,
generic_message: Self::create_channel(&connection).await,
message_sent: Self::create_channel(&connection).await,
mass_mention_message_sent: Self::create_channel(&connection).await,
ack_notification_message: Self::create_channel(&connection).await,
dm_call_updated: Self::create_channel(&connection).await,
process_ack: Self::create_channel(&connection).await,
connection,
channel,
}
}
pub async fn new_auto() -> AMQP {
pub async fn new_auto() -> Self {
let config = revolt_config::config().await;
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(),
let connection = Arc::new(
Connection::connect(
&format!(
"amqp://{}:{}@{}:{}",
&config.rabbit.username,
&config.rabbit.password,
&config.rabbit.host,
&config.rabbit.port,
),
ConnectionProperties::default(),
)
.await
.expect("Failed to declare exchange");
.expect("Failed to connect to RabbitMQ"),
);
AMQP::new(connection, channel)
Self::new(connection).await
}
async fn create_channel(connection: &Connection) -> Arc<Channel> {
Arc::new(
connection
.create_channel()
.await
.expect("Failed to create channel"),
)
}
pub async fn friend_request_accepted(
@@ -72,19 +91,20 @@ impl AMQP {
config.pushd.get_fr_accepted_routing_key(),
payload
);
self.channel
self.friend_request_accepted
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_fr_accepted_routing_key(),
),
config.pushd.exchange.clone().into(),
config.pushd.get_fr_accepted_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
pub async fn friend_request_received(
@@ -105,19 +125,19 @@ impl AMQP {
payload
);
self.channel
self.friend_request_received
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_fr_received_routing_key(),
),
config.pushd.exchange.clone().into(),
config.pushd.get_fr_received_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
pub async fn generic_message(
@@ -142,19 +162,19 @@ impl AMQP {
payload
);
self.channel
self.generic_message
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_generic_routing_key(),
),
config.pushd.exchange.clone().into(),
config.pushd.get_generic_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
pub async fn message_sent(
@@ -185,19 +205,19 @@ impl AMQP {
payload
);
self.channel
self.message_sent
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_message_routing_key(),
),
config.pushd.exchange.clone().into(),
config.pushd.get_message_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
pub async fn mass_mention_message_sent(
@@ -220,19 +240,24 @@ impl AMQP {
routing_key, payload
);
self.channel
self.mass_mention_message_sent
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(&config.pushd.exchange, routing_key.as_str()),
config.pushd.exchange.clone().into(),
routing_key.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
pub async fn ack_message(
/// # Sends an ack to pushd to update badges on iPhones.
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
&self,
user_id: String,
channel_id: String,
@@ -252,23 +277,25 @@ impl AMQP {
config.pushd.ack_queue, payload
);
let mut headers = FieldTable::new();
let mut headers = FieldTable::default();
headers.insert(
"x-deduplication-header".try_into().unwrap(),
format!("{}-{}", &user_id, &channel_id).into(),
"x-deduplication-header".into(),
AMQPValue::LongString(format!("{}-{}", &user_id, &channel_id).into()),
);
self.channel
self.ack_notification_message
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
//.with_headers(headers)
.finish(),
payload.into(),
BasicPublishArguments::new(&config.pushd.exchange, &config.pushd.ack_queue),
config.pushd.exchange.clone().into(),
config.pushd.ack_queue.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
/// # DM Call Update
@@ -302,18 +329,54 @@ impl AMQP {
payload
);
self.channel
self.dm_call_updated
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_dm_call_routing_key(),
),
config.pushd.exchange.clone().into(),
config.pushd.get_dm_call_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.await?;
Ok(())
}
/// # Send an ack to crond for processing
pub async fn process_ack(
&self,
user_id: &str,
channel_id: Option<&str>,
server_id: Option<&str>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckEventPayload {
user_id: user_id.to_string(),
channel_id: channel_id.map(|value| value.to_string()),
server_id: server_id.map(|value| value.to_string()),
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack processor event on exchange {}, channel {}: {}",
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
);
self.process_ack
.basic_publish(
config.rabbit.default_exchange.clone().into(),
config.rabbit.queues.acks.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
}

View File

@@ -2,16 +2,7 @@
mod mongodb;
mod reference;
use authifier::config::Captcha;
use authifier::config::EmailVerificationConfig;
use authifier::config::PasswordScanning;
use authifier::config::ResolveIp;
use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::config::EmailExpiryConfig;
use authifier::Authifier;
use rand::Rng;
use revolt_config::config;
@@ -112,143 +103,3 @@ impl DatabaseInfo {
}
}
}
impl Database {
/// Create an Authifier reference
pub async fn to_authifier(self) -> Authifier {
let config = config().await;
let mut auth_config = authifier::Config {
password_scanning: if config.api.security.easypwned.is_empty() {
Default::default()
} else {
PasswordScanning::EasyPwned {
endpoint: config.api.security.easypwned,
}
},
email_verification: if !config.api.smtp.host.is_empty() {
EmailVerificationConfig::Enabled {
smtp: SMTPSettings {
from: config.api.smtp.from_address,
host: config.api.smtp.host,
username: config.api.smtp.username,
password: config.api.smtp.password,
reply_to: Some(
config
.api
.smtp
.reply_to
.unwrap_or("support@stoat.chat".into()),
),
port: config.api.smtp.port,
use_tls: config.api.smtp.use_tls,
use_starttls: config.api.smtp.use_starttls,
},
expiry: EmailExpiryConfig {
expire_verification: 3600 * 24 * 7,
expire_password_reset: 3600 * 24,
expire_account_deletion: 3600 * 24,
},
templates: if config.production {
Templates {
verify: Template {
title: "Verify your Stoat account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Stoat password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.html").into()),
},
reset_existing: Template {
title: "You already have a Stoat account, reset your password."
.into(),
text: include_str!("../../templates/reset-existing.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(
include_str!("../../templates/reset-existing.html").into(),
),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
welcome: None,
}
} else {
Templates {
verify: Template {
title: "Verify your account.".into(),
text: include_str!("../../templates/verify.whitelabel.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: None,
},
reset: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
reset_existing: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.whitelabel.txt")
.into(),
url: format!("{}/delete/", config.hosts.app),
html: None,
},
welcome: None,
}
},
}
} else {
EmailVerificationConfig::Disabled
},
..Default::default()
};
auth_config.invite_only = config.api.registration.invite_only;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
auth_config.captcha = Captcha::HCaptcha {
secret: config.api.security.captcha.hcaptcha_key,
};
}
if !config.api.security.authifier_shield_key.is_empty() {
auth_config.shield = Shield::Enabled {
api_key: config.api.security.authifier_shield_key,
strict: false,
};
}
if config.api.security.trust_cloudflare {
auth_config.resolve_ip = ResolveIp::Cloudflare;
}
Authifier {
database: match self {
Database::Reference(_) => Default::default(),
#[cfg(feature = "mongodb")]
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
},
config: auth_config,
#[cfg(feature = "tasks")]
event_channel: Some(crate::tasks::authifier_relay::sender()),
#[cfg(not(feature = "tasks"))]
event_channel: None,
}
}
}

View File

@@ -3,9 +3,9 @@ use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex;
use crate::{
AuditLogEntry, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite,
Member, MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan,
Snapshot, User, UserSettings, Webhook,
Account, AccountInvite, AuditLogEntry, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji,
File, FileHash, Invite, MFATicket, Member, MemberCompositeKey, Message, PolicyChange,
RatelimitEvent, Report, Server, ServerBan, Session, Snapshot, User, UserSettings, Webhook,
};
database_derived!(
@@ -31,5 +31,9 @@ database_derived!(
pub servers: Arc<Mutex<HashMap<String, Server>>>,
pub safety_reports: Arc<Mutex<HashMap<String, Report>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, Snapshot>>>,
pub accounts: Arc<Mutex<HashMap<String, Account>>>,
pub account_invites: Arc<Mutex<HashMap<String, AccountInvite>>>,
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
pub tickets: Arc<Mutex<HashMap<String, MFATicket>>>,
}
);

View File

@@ -1,12 +1,16 @@
use authifier::AuthifierEvent;
use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
AppendMessage, Channel, ChannelSlowmode, ChannelUnread, ChannelVoiceState, Emoji,
FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser,
FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji,
PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings,
UserVoiceState, Webhook,
};
use crate::Database;
use crate::{Account, Database, Session};
/// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -51,9 +55,13 @@ impl Default for ReadyPayloadFields {
#[serde(tag = "type")]
pub enum EventV1 {
/// Multiple events
Bulk { v: Vec<EventV1> },
Bulk {
v: Vec<EventV1>,
},
/// Error event
Error { data: Error },
Error {
data: Error,
},
/// Successfully authenticated
Authenticated,
@@ -84,7 +92,9 @@ pub enum EventV1 {
},
/// Ping response
Pong { data: Ping },
Pong {
data: Ping,
},
/// New message
Message(Message),
@@ -105,7 +115,10 @@ pub enum EventV1 {
},
/// Delete message
MessageDelete { id: String, channel: String },
MessageDelete {
id: String,
channel: String,
},
/// New reaction to a message
MessageReact {
@@ -131,7 +144,10 @@ pub enum EventV1 {
},
/// Bulk delete messages
BulkMessageDelete { channel: String, ids: Vec<String> },
BulkMessageDelete {
channel: String,
ids: Vec<String>,
},
/// New server
ServerCreate {
@@ -139,7 +155,7 @@ pub enum EventV1 {
server: Server,
channels: Vec<Channel>,
emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>
voice_states: Vec<ChannelVoiceState>,
},
/// Update existing server
@@ -151,7 +167,9 @@ pub enum EventV1 {
},
/// Delete server
ServerDelete { id: String },
ServerDelete {
id: String,
},
/// Update existing server member
ServerMemberUpdate {
@@ -187,10 +205,16 @@ pub enum EventV1 {
},
/// Server role deleted
ServerRoleDelete { id: String, role_id: String },
ServerRoleDelete {
id: String,
role_id: String,
},
/// Server roles ranks updated
ServerRoleRanksUpdate { id: String, ranks: Vec<String> },
ServerRoleRanksUpdate {
id: String,
ranks: Vec<String>,
},
/// Update existing user
UserUpdate {
@@ -202,9 +226,15 @@ pub enum EventV1 {
},
/// Relationship with another user changed
UserRelationship { id: String, user: User },
UserRelationship {
id: String,
user: User,
},
/// Settings updated remotely
UserSettingsUpdate { id: String, update: UserSettings },
UserSettingsUpdate {
id: String,
update: UserSettings,
},
/// User has been platform banned or deleted their account
///
@@ -215,12 +245,23 @@ pub enum EventV1 {
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { user_id: String, flags: i32 },
UserPlatformWipe {
user_id: String,
flags: i32,
},
/// New emoji
EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji
EmojiDelete { id: String },
EmojiDelete {
id: String,
},
/// New report
ReportCreate(Report),
@@ -236,19 +277,33 @@ pub enum EventV1 {
},
/// Delete channel
ChannelDelete { id: String },
ChannelDelete {
id: String,
},
/// User joins a group
ChannelGroupJoin { id: String, user: String },
ChannelGroupJoin {
id: String,
user: String,
},
/// User leaves a group
ChannelGroupLeave { id: String, user: String },
ChannelGroupLeave {
id: String,
user: String,
},
/// User started typing in a channel
ChannelStartTyping { id: String, user: String },
ChannelStartTyping {
id: String,
user: String,
},
/// User stopped typing in a channel
ChannelStopTyping { id: String, user: String },
ChannelStopTyping {
id: String,
user: String,
},
/// User acknowledged message in channel
ChannelAck {
@@ -268,10 +323,25 @@ pub enum EventV1 {
},
/// Delete webhook
WebhookDelete { id: String },
WebhookDelete {
id: String,
},
/// Auth events
Auth(AuthifierEvent),
CreateAccount {
account: Account,
},
CreateSession {
session: Session,
},
DeleteSession {
user_id: String,
session_id: String,
},
DeleteAllSessions {
user_id: String,
exclude_session_id: Option<String>,
},
/// Voice events
VoiceChannelJoin {
@@ -286,7 +356,7 @@ pub enum EventV1 {
user: String,
from: String,
to: String,
state: UserVoiceState
state: UserVoiceState,
},
UserVoiceStateUpdate {
id: String,
@@ -298,7 +368,11 @@ pub enum EventV1 {
from: String,
to: String,
token: String,
}
},
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
}
impl EventV1 {

View File

@@ -78,3 +78,11 @@ pub struct AckPayload {
pub channel_id: String,
pub message_id: String,
}
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
#[derive(Serialize, Deserialize, Debug)]
pub struct AckEventPayload {
pub user_id: String,
pub channel_id: Option<String>,
pub server_id: Option<String>,
}

View File

@@ -0,0 +1,5 @@
mod model;
mod ops;
pub use model::*;
pub use ops::*;

View File

@@ -0,0 +1,25 @@
use crate::{if_false, Database};
use revolt_result::Result;
auto_derived_partial!(
/// Account invite ticket
pub struct AccountInvite {
/// Invite code
#[serde(rename = "_id")]
pub id: String,
/// Whether this invite ticket has been used
#[serde(skip_serializing_if = "if_false", default)]
pub used: bool,
/// User ID that this invite was claimed by
#[serde(skip_serializing_if = "Option::is_none")]
pub claimed_by: Option<String>,
},
"PartialAccountInvite"
);
impl AccountInvite {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_account_invite(self).await
}
}

View File

@@ -0,0 +1,16 @@
use revolt_result::Result;
use crate::AccountInvite;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAccountInvites: Sync + Send {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite>;
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()>;
}

View File

@@ -0,0 +1,31 @@
use crate::{AbstractAccountInvites, AccountInvite, MongoDb};
use bson::to_document;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
const COL: &str = "account_invites";
#[async_trait]
impl AbstractAccountInvites for MongoDb {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(InvalidInvite))
}
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()> {
self.col::<AccountInvite>(COL)
.update_one(
doc! {
"_id": &invite.id
},
doc! {
"$set": to_document(invite).map_err(|_| create_database_error!("to_document", COL))?,
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("upsert_one", COL))
.map(|_| ())
}
}

View File

@@ -0,0 +1,21 @@
use crate::{AbstractAccountInvites, AccountInvite, ReferenceDb};
use revolt_result::Result;
#[async_trait]
impl AbstractAccountInvites for ReferenceDb {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite> {
let invites = self.account_invites.lock().await;
invites
.get(id)
.cloned()
.ok_or_else(|| create_error!(InvalidInvite))
}
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()> {
let mut invites = self.account_invites.lock().await;
invites.insert(invite.id.to_string(), invite.clone());
Ok(())
}
}

View File

@@ -0,0 +1,22 @@
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{Error, Result};
use crate::{Account, Database, Session};
#[async_trait]
impl<S> FromRequestParts<S> for Account
where
Database: FromRef<S>,
S: Send + Sync
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let db = Database::from_ref(state);
let session = Session::from_request_parts(parts, state).await?;
db.fetch_account(&session.user_id).await
}
}

View File

@@ -0,0 +1,11 @@
#[cfg(feature = "axum-impl")]
mod axum;
mod model;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
#[cfg(feature = "rocket-impl")]
mod schema;
pub use model::*;
pub use ops::*;

View File

@@ -0,0 +1,656 @@
use iso8601_timestamp::{Duration, Timestamp};
use nanoid::nanoid;
use revolt_config::config;
use revolt_result::Result;
use serde_json::json;
use crate::{
events::client::EventV1,
util::{
email::{email_templates, normalise_email, send_email},
password::hash_password,
},
Database, MFATicket, Session,
};
use revolt_models::v0;
auto_derived_partial!(
/// Account model
pub struct Account {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// User's email
pub email: String,
/// Normalised email
///
/// (see https://github.com/insertish/authifier/#how-does-authifier-work)
pub email_normalised: String,
/// Argon2 hashed password
pub password: String,
/// Whether the account is disabled
#[serde(default)]
pub disabled: bool,
/// Email verification status
pub verification: EmailVerification,
/// Password reset information
pub password_reset: Option<PasswordReset>,
/// Account deletion information
pub deletion: Option<DeletionInfo>,
/// Account lockout
pub lockout: Option<Lockout>,
/// Multi-factor authentication information
pub mfa: MultiFactorAuthentication,
},
"PartialAccount"
);
auto_derived!(
/// Email verification status
#[serde(tag = "status")]
pub enum EmailVerification {
/// Account is verified
Verified,
/// Pending email verification
Pending { token: String, expiry: Timestamp },
/// Moving to a new email
Moving {
new_email: String,
token: String,
expiry: Timestamp,
},
}
/// Password reset information
pub struct PasswordReset {
/// Token required to change password
pub token: String,
/// Time at which this token expires
pub expiry: Timestamp,
}
/// Account deletion information
#[serde(tag = "status")]
pub enum DeletionInfo {
/// The user must confirm deletion by email
WaitingForVerification { token: String, expiry: Timestamp },
/// The account is scheduled for deletion
Scheduled { after: Timestamp },
/// This account was deleted
Deleted,
}
/// Lockout information
pub struct Lockout {
/// Attempt counter
pub attempts: i32,
/// Time at which this lockout expires
pub expiry: Option<Timestamp>,
}
/// MFA configuration
#[derive(Default)]
pub struct MultiFactorAuthentication {
/// Allow password-less email OTP login
/// (1-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_email_otp: bool,
/// Allow trusted handover
/// (1-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_trusted_handover: bool,
/// Allow email MFA
/// (2-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_email_mfa: bool,
/// TOTP MFA token, enabled if present
/// (2-Factor)
#[serde(skip_serializing_if = "Totp::is_empty", default)]
pub totp_token: Totp,
/// Security Key MFA token, enabled if present
/// (2-Factor)
// #[serde(skip_serializing_if = "Option::is_none")]
// pub security_key_token: Option<String>,
/// Recovery codes
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub recovery_codes: Vec<String>,
}
/// MFA method
#[derive(Hash)]
pub enum MFAMethod {
Password,
Recovery,
Totp,
}
#[derive(Default)]
#[serde(tag = "status")]
pub enum Totp {
/// Disabled
#[default]
Disabled,
/// Waiting for user activation
Pending { secret: String },
/// Required on account
Enabled { secret: String },
}
);
impl MultiFactorAuthentication {
// Check whether MFA is in-use
pub fn is_active(&self) -> bool {
matches!(self.totp_token, Totp::Enabled { .. })
}
// Check whether there are still usable recovery codes
pub fn has_recovery(&self) -> bool {
!self.recovery_codes.is_empty()
}
// Get available MFA methods
pub fn get_methods(&self) -> Vec<MFAMethod> {
if let Totp::Enabled { .. } = self.totp_token {
let mut methods = vec![MFAMethod::Totp];
if self.has_recovery() {
methods.push(MFAMethod::Recovery);
}
methods
} else {
vec![MFAMethod::Password]
}
}
// Generate new recovery codes
pub fn generate_recovery_codes(&mut self) {
static ALPHABET: [char; 32] = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z',
];
let mut codes = vec![];
for _ in 1..=10 {
codes.push(format!(
"{}-{}",
nanoid!(5, &ALPHABET),
nanoid!(5, &ALPHABET)
));
}
self.recovery_codes = codes;
}
// Generate new TOTP secret
pub fn generate_new_totp_secret(&mut self) -> Result<String> {
if let Totp::Enabled { .. } = self.totp_token {
return Err(create_error!(OperationFailed));
}
let secret: [u8; 10] = rand::random();
let secret = base32::encode(base32::Alphabet::RFC4648 { padding: false }, &secret);
self.totp_token = Totp::Pending {
secret: secret.clone(),
};
Ok(secret)
}
/// Enable TOTP using a given MFA response
pub fn enable_totp(&mut self, response: v0::MFAResponse) -> Result<()> {
if let v0::MFAResponse::Totp { totp_code } = response {
let code = self.totp_token.generate_code()?;
if code == totp_code {
let mut totp = Totp::Disabled;
std::mem::swap(&mut totp, &mut self.totp_token);
if let Totp::Pending { secret } = totp {
self.totp_token = Totp::Enabled { secret };
Ok(())
} else {
Err(create_error!(OperationFailed))
}
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
}
impl Totp {
/// Whether TOTP information is empty
pub fn is_empty(&self) -> bool {
matches!(self, Totp::Disabled)
}
/// Whether TOTP is disabled
pub fn is_disabled(&self) -> bool {
!matches!(self, Totp::Enabled { .. })
}
// Generate a TOTP code from secret
pub fn generate_code(&self) -> Result<String> {
if let Totp::Enabled { secret } | Totp::Pending { secret } = &self {
let seconds: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(totp_lite::totp_custom::<totp_lite::Sha1>(
totp_lite::DEFAULT_STEP,
6,
&base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret)
.expect("valid base32 secret"),
seconds,
))
} else {
Err(create_error!(OperationFailed))
}
}
}
impl Account {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_account(self).await
}
/// Create a new account
pub async fn new(
db: &Database,
email: String,
plaintext_password: String,
verify_email: bool,
) -> Result<Account> {
// Get a normalised representation of the user's email
let email_normalised = normalise_email(email.clone());
// Try to find an existing account
if let Some(mut account) = db
.fetch_account_by_normalised_email(&email_normalised)
.await?
{
// Resend account verification or send password reset
if let EmailVerification::Pending { .. } = &account.verification {
account.start_email_verification(db).await?;
} else {
account.start_password_reset(db, true).await?;
}
Ok(account)
} else {
// Hash the user's password
let password = hash_password(plaintext_password)?;
// Create a new account
let mut account = Account {
id: ulid::Ulid::new().to_string(),
email,
email_normalised,
password,
disabled: false,
verification: EmailVerification::Verified,
password_reset: None,
deletion: None,
lockout: None,
mfa: Default::default(),
};
// Send email verification
if verify_email {
account.start_email_verification(db).await?;
} else {
account.save(db).await?;
}
// Create and push event
EventV1::CreateAccount {
account: account.clone(),
}
.global()
.await;
Ok(account)
}
}
/// Create a new session
pub async fn create_session(&self, db: &Database, name: String) -> Result<Session> {
let config = config().await;
let session = Session {
id: ulid::Ulid::new().to_string(),
token: nanoid!(64),
user_id: self.id.clone(),
name,
last_seen: Timestamp::now_utc(),
origin: Some(config.environment),
subscription: None,
};
// Save to database
db.save_session(&session).await?;
// Create and push event
EventV1::CreateSession {
session: session.clone(),
}
.global()
.await;
Ok(session)
}
/// Send account verification email
pub async fn start_email_verification(&mut self, db: &Database) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.verify.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
&templates.verify,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.verification = EmailVerification::Pending {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_verification,
))
.unwrap(),
};
} else {
self.verification = EmailVerification::Verified;
}
self.save(db).await
}
/// Send account verification to new email
pub async fn start_email_move(&mut self, db: &Database, new_email: String) -> Result<()> {
// This method should and will never be called on an unverified account,
// but just validate this just in case.
if let EmailVerification::Pending { .. } = self.verification {
return Err(create_error!(UnverifiedAccount));
}
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.verify.url, token);
send_email(
&config.api.smtp,
new_email.clone(),
&templates.verify,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.verification = EmailVerification::Moving {
new_email,
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_verification,
))
.unwrap(),
};
} else {
self.email_normalised = normalise_email(new_email.clone());
self.email = new_email;
}
self.save(db).await
}
/// Send password reset email
pub async fn start_password_reset(
&mut self,
db: &Database,
existing_account: bool,
) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let template = if existing_account {
&templates.reset_existing
} else {
&templates.reset
};
let token = nanoid!(32);
let url = format!("{}{}", template.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
template,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.password_reset = Some(PasswordReset {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_password_reset,
))
.unwrap(),
});
} else {
return Err(create_error!(OperationFailed));
}
self.save(db).await
}
/// Begin account deletion process by sending confirmation email
///
/// If email verification is not on, the account will be marked for deletion instantly
pub async fn start_account_deletion(&mut self, db: &Database) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.deletion.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
&templates.deletion,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.deletion = Some(DeletionInfo::WaitingForVerification {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_password_reset,
))
.unwrap(),
});
self.save(db).await
} else {
self.schedule_deletion(db).await
}
}
/// Verify a user's password is correct
pub fn verify_password(&self, plaintext_password: &str) -> Result<()> {
argon2::verify_encoded(&self.password, plaintext_password.as_bytes())
.map(|v| {
if v {
Ok(())
} else {
Err(create_error!(InvalidCredentials))
}
})
// To prevent user enumeration, we should ignore
// the error and pretend the password is wrong.
.map_err(|_| create_error!(InvalidCredentials))?
}
/// Validate an MFA response
pub async fn consume_mfa_response(
&mut self,
db: &Database,
response: v0::MFAResponse,
ticket: Option<MFATicket>,
) -> Result<()> {
let allowed_methods = self.mfa.get_methods();
match response {
v0::MFAResponse::Password { password } => {
if allowed_methods.contains(&MFAMethod::Password) {
self.verify_password(&password)
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
v0::MFAResponse::Totp { totp_code } => {
if allowed_methods.contains(&MFAMethod::Totp) {
if let Totp::Enabled { .. } = &self.mfa.totp_token {
// Use TOTP code at generation if applicable
if let Some(ticket) = ticket {
if let Some(code) = ticket.last_totp_code {
if code == totp_code {
return Ok(());
}
}
}
// Otherwise read current TOTP token
if self.mfa.totp_token.generate_code()? == totp_code {
Ok(())
} else {
Err(create_error!(InvalidToken))
}
} else {
unreachable!()
}
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
v0::MFAResponse::Recovery { recovery_code } => {
if allowed_methods.contains(&MFAMethod::Recovery) {
if let Some(index) = self
.mfa
.recovery_codes
.iter()
.position(|x| x == &recovery_code)
{
self.mfa.recovery_codes.remove(index);
self.save(db).await
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
}
}
/// Delete all sessions for an account
pub async fn delete_all_sessions(
&self,
db: &Database,
exclude_session_id: Option<String>,
) -> Result<()> {
db.delete_all_sessions(&self.id, exclude_session_id.clone())
.await?;
// Create and push event
EventV1::DeleteAllSessions {
user_id: self.id.clone(),
exclude_session_id,
}
.private(self.id.clone())
.await;
Ok(())
}
/// Disable an account
pub async fn disable(&mut self, db: &Database) -> Result<()> {
self.disabled = true;
self.delete_all_sessions(db, None).await?;
self.save(db).await
}
/// Schedule an account for deletion
pub async fn schedule_deletion(&mut self, db: &Database) -> Result<()> {
self.deletion = Some(DeletionInfo::Scheduled {
after: Timestamp::now_utc()
.checked_add(Duration::weeks(1))
.unwrap(),
});
self.disable(db).await
}
/// Removes all information from the account and marks it as fully deleted
pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> {
self.email = format!("Deleted User {}", &self.id);
self.email_normalised = format!("Deleted User {}", &self.id);
self.deletion = Some(DeletionInfo::Deleted);
self.save(db).await?;
Ok(())
}
}

View File

@@ -0,0 +1,34 @@
use revolt_result::Result;
use crate::Account;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAccounts: Sync + Send {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account>;
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>>;
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token: &str) -> Result<Account>;
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account>;
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token: &str) -> Result<Account>;
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>>;
// Save account
async fn save_account(&self, account: &Account) -> Result<()>;
}

View File

@@ -0,0 +1,118 @@
use crate::{AbstractAccounts, Account, MongoDb};
use bson::{to_bson, to_document};
use iso8601_timestamp::Timestamp;
use mongodb::options::{Collation, CollationStrength, FindOneOptions, UpdateOptions};
use revolt_result::Result;
const COL: &str = "accounts";
#[async_trait]
impl AbstractAccounts for MongoDb {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(UnknownUser))
}
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>> {
query!(
self,
find_one_with_options,
COL,
doc! {
"email_normalised": normalised_email
},
FindOneOptions::builder()
.collation(
Collation::builder()
.locale("en")
.strength(CollationStrength::Secondary)
.build(),
)
.build()
)
}
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"verification.token": token,
"verification.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"password_reset.token": token,
"password_reset.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"deletion.token": token,
"deletion.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>> {
query!(
self,
find,
COL,
doc! {
"deletion.status": "Scheduled",
"deletion.after": {
"$lte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)
}
// Save account
async fn save_account(&self, account: &Account) -> Result<()> {
self.col::<Account>(COL)
.update_one(
doc! {
"_id": &account.id
},
doc! {
"$set": to_document(account).map_err(|_| create_database_error!("to_document", COL))?
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("find_one", COL))
.map(|_| ())
}
}

View File

@@ -0,0 +1,99 @@
use crate::{AbstractAccounts, Account, DeletionInfo, EmailVerification, ReferenceDb};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
#[async_trait]
impl AbstractAccounts for ReferenceDb {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.get(id)
.cloned()
.ok_or_else(|| create_error!(UnknownUser))
}
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>> {
let accounts = self.accounts.lock().await;
Ok(accounts
.values()
.find(|account| account.email_normalised == normalised_email)
.cloned())
}
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token_to_match: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| match &account.verification {
EmailVerification::Pending { token, .. }
| EmailVerification::Moving { token, .. } => token == token_to_match,
_ => false,
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| {
if let Some(reset) = &account.password_reset {
reset.token == token
} else {
false
}
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token_to_match: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| {
if let Some(DeletionInfo::WaitingForVerification { token, .. }) = &account.deletion
{
token == token_to_match
} else {
false
}
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>> {
let now = Timestamp::now_utc();
let accounts = self.accounts.lock().await;
Ok(accounts
.values()
.filter(|account| {
if let Some(DeletionInfo::Scheduled { after }) = &account.deletion {
after <= &now
} else {
false
}
})
.cloned()
.collect())
}
// Save account
async fn save_account(&self, account: &Account) -> Result<()> {
let mut accounts = self.accounts.lock().await;
accounts.insert(account.id.to_string(), account.clone());
Ok(())
}
}

View File

@@ -0,0 +1,32 @@
use crate::{Account, Database, Session};
use revolt_result::Error;
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Account {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.guard::<Session>().await {
Outcome::Success(session) => {
if let Ok(account) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_account(&session.user_id)
.await
{
Outcome::Success(account)
} else {
Outcome::Error((Status::InternalServerError, create_error!(InternalError)))
}
}
Outcome::Forward(_) => unreachable!(),
Outcome::Error(err) => Outcome::Error(err),
}
}
}

View File

@@ -0,0 +1,32 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::Account;
impl<'r> OpenApiFromRequest<'r> for Account {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Session Token".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Session Token".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-session-token".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authenticate as a user.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -101,6 +101,17 @@ pub async fn create_database(db: &MongoDb) {
db.create_collection("audit_logs")
.await
.expect("Failed to create audit_logs collection");
db.create_collection("sessions")
.await
.expect("Failed to create sessions collection.");
db.create_collection("account_invites")
.await
.expect("Failed to create account_invites collection.");
db.create_collection("mfa_tickets")
.await
.expect("Failed to create mfa_tickets collection.");
db.run_command(doc! {
"createIndexes": "users",
@@ -293,5 +304,115 @@ pub async fn create_database(db: &MongoDb) {
.await
.expect("Failed to create audit_logs index");
db.run_command(doc! {
"createIndexes": "audit_logs",
"indexes": [
{
"key": {
"expires_at": 1_i32,
},
"name": "expires_at_ttl",
// We set the expire after to 0 because we store when it expires instead of when the document was inserted,
// this is because mongo cant read the timestamp from the ulid so we need to do this workaround.
// relevant docs: https://www.mongodb.com/docs/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time
"expireAfterSeconds": 0
},
{
"key": {
"server": 1_i32,
"user": 1_i32,
"action.type": 1_i32,
},
"name": "audit_log_filters",
},
]
})
.await
.expect("Failed to create audit_logs index");
db.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"email": 1
},
"name": "email",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"email_normalised": 1
},
"name": "email_normalised",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"verification.token": 1
},
"name": "email_verification"
},
{
"key": {
"password_reset.token": 1
},
"name": "password_reset"
},
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
info!("Created database.");
}

View File

@@ -17,6 +17,7 @@ use iso8601_timestamp::Timestamp;
use rand::seq::SliceRandom;
use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Serialize, Deserialize)]
@@ -25,7 +26,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 51; // MUST BE +1 to last migration
pub const LATEST_REVISION: i32 = 52; // MUST BE +1 to last migration
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -573,20 +574,145 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
if revision <= 15 {
info!("Running migration [revision 15 / 04-06-2022]: Migrate Authifier to latest version.");
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
db.run_migration(authifier::Migration::M2022_06_03EnsureUpToSpec)
if !db
.db()
.collection::<Document>("mfa_tickets")
.list_index_names()
.await
.unwrap();
.unwrap_or_default()
.contains(&"token".to_owned())
{
// Make sure all collections exist
let list = db.db().list_collection_names().await.unwrap();
let collections = ["accounts", "sessions", "invites", "mfa_tickets"];
for name in collections {
if !list.contains(&name.to_string()) {
db.db().create_collection(name).await.unwrap();
}
}
// Setup index for `accounts`
let col = db.db().collection::<Document>("accounts");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"email": 1
},
"name": "email",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"email_normalised": 1
},
"name": "email_normalised",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"verification.token": 1
},
"name": "email_verification"
},
{
"key": {
"password_reset.token": 1
},
"name": "password_reset"
}
]
})
.await
.unwrap();
// Setup index for `sessions`
let col = db.db().collection::<Document>("sessions");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
// Setup index for `mfa_tickets`
let col = db.db().collection::<Document>("mfa_tickets");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
}
}
if revision <= 16 {
info!("Running migration [revision 16 / 07-07-2022]: Add `emojis` collection and Authifier migration.");
let authifier_db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
authifier_db
.run_migration(authifier::Migration::M2022_06_09AddIndexForDeletion)
if !db
.db()
.collection::<Document>("accounts")
.list_index_names()
.await
.unwrap();
.expect("list of index names")
.contains(&"account_deletion".to_owned())
{
db.db()
.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
}
db.db()
.create_collection("emojis")
@@ -1085,7 +1211,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
enum Channel {
Group { owner: String },
TextChannel { server: String },
VoiceChannel { server: String }
VoiceChannel { server: String },
}
let webhooks = db
@@ -1099,7 +1225,12 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await;
for webhook in webhooks {
match db.col::<Channel>("channels").find_one(doc! { "_id": &webhook.channel_id }).await.unwrap() {
match db
.col::<Channel>("channels")
.find_one(doc! { "_id": &webhook.channel_id })
.await
.unwrap()
{
Some(channel) => {
let creator_id = match channel {
Channel::Group { owner, .. } => owner,
@@ -1141,10 +1272,51 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
"Running migration [revision 32 / 12-05-2025]: (Authifier) Add last_seen to sessions."
);
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
db.run_migration(authifier::Migration::M2025_02_20AddLastSeenToSession)
.await
.unwrap();
loop {
#[derive(Deserialize)]
struct SessionId {
_id: String,
}
let sessions: Vec<SessionId> = db
.db()
.collection("sessions")
.find(doc! {
"$or": [
{ "last_seen": { "$exists": false } },
{ "last_seen": "1970-01-01T00:00:00.000Z" }
]
})
.limit(50_000) // about 400 batches for 2 million
.await
.expect("Failed to create cursor for sessions!")
.map(|doc| doc.expect("id and username"))
.collect()
.await;
if sessions.is_empty() {
break;
}
for session in sessions {
let timestamp = iso8601_timestamp::Timestamp::from(Ulid::from_string(&session._id).unwrap().datetime());
db.db()
.collection::<Document>("sessions")
.update_one(
doc! {
"_id": &session._id.to_string(),
},
doc! {
"$set": {
"last_seen": timestamp.format().to_string()
}
},
)
.await
.expect("Failed to update a session.");
}
}
}
if revision <= 40 {
@@ -1240,7 +1412,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
"channel_type": "TextChannel",
"voice": {}
}
}
},
)
.await
.expect("Failed to update voice channels");
@@ -1292,10 +1464,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
let mut doc = doc! {};
for id in server.roles.keys() {
doc.insert(
format!("roles.{id}._id"),
id,
);
doc.insert(format!("roles.{id}._id"), id);
}
db.db()
@@ -1306,8 +1475,23 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
}
};
if revision >= 50 {
info!("Running migration [revision 50 / 28-11-2025]: Add audit logs collection");
if revision <= 50 {
info!("Running migration [revision 50 / 13-04-2026]: Rename invites collection to account_invites");
db.db()
.client()
.database("admin")
.run_command(doc! {
"renameCollection": "revolt.invites",
"to": "revolt.account_invites",
"dropTarget": true
})
.await
.unwrap();
}
if revision >= 51 {
info!("Running migration [revision 51 / 28-11-2025]: Add audit logs collection");
db.db()
.create_collection("audit_logs")

View File

@@ -1,6 +1,7 @@
#![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap};
use redis_kiss::get_connection;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField;
@@ -212,7 +213,7 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()),
slowmode: None
slowmode: None,
},
v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(),
@@ -225,7 +226,7 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None
slowmode: None,
},
};
@@ -407,7 +408,10 @@ impl Channel {
/// Check whether has a user as a recipient
pub fn contains_user(&self, user_id: &str) -> bool {
match self {
Channel::Group { recipients, .. } => recipients.contains(&String::from(user_id)),
Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
recipients.iter().any(|recipient| recipient == user_id)
}
Channel::SavedMessages { user, .. } => user == user_id,
_ => false,
}
}
@@ -415,7 +419,9 @@ impl Channel {
/// Get list of recipients
pub fn users(&self) -> Result<Vec<String>> {
match self {
Channel::Group { recipients, .. } => Ok(recipients.to_owned()),
Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
Ok(recipients.to_owned())
}
_ => Err(create_error!(NotFound)),
}
}
@@ -754,7 +760,7 @@ impl Channel {
}
/// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> {
EventV1::ChannelAck {
id: self.id().to_string(),
user: user.to_string(),
@@ -763,17 +769,7 @@ impl Channel {
.private(user.to_string())
.await;
#[cfg(feature = "tasks")]
crate::tasks::ack::queue_ack(
self.id().to_string(),
user.to_string(),
crate::tasks::ack::AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
crate::util::acker::ack_channel(user, self.id(), message, amqp).await
}
/// Remove user from a group

View File

@@ -1,4 +1,4 @@
use crate::{revolt_result::Result, Channel, FieldsChannel, PartialChannel};
use crate::{Channel, FieldsChannel, PartialChannel, revolt_result::Result, util::ChunkedDatabaseGenerator};
use revolt_permissions::OverrideField;
#[cfg(feature = "mongodb")]
@@ -19,6 +19,9 @@ pub trait AbstractChannels: Sync + Send {
/// Fetch all direct messages for a user
async fn find_direct_messages(&self, user_id: &str) -> Result<Vec<Channel>>;
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>>;
// Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel>;
@@ -47,6 +50,9 @@ pub trait AbstractChannels: Sync + Send {
// Remove a user from a group
async fn remove_user_from_group(&self, channel_id: &str, user_id: &str) -> Result<()>;
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()>;
// Delete a channel
async fn delete_channel(&self, channel_id: &Channel) -> Result<()>;
}

View File

@@ -1,7 +1,8 @@
use super::AbstractChannels;
use crate::{AbstractServers, Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel};
use crate::{AbstractServers, Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel, util::ChunkedDatabaseGenerator};
use bson::{Bson, Document};
use futures::StreamExt;
use mongodb::options::ReadConcern;
use revolt_permissions::OverrideField;
use revolt_result::Result;
@@ -69,6 +70,32 @@ impl AbstractChannels for MongoDb {
)
}
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>> {
let mut session = self
.start_session()
.await
.map_err(|_| create_database_error!("start_session", COL))?;
session
.start_transaction()
.read_concern(ReadConcern::snapshot())
.await
.map_err(|_| create_database_error!("start_transaction", COL))?;
let cursor = self.col(COL)
.find(doc! {
"channel_type": "Group",
"recipients": user_id
})
.session(&mut session)
.batch_size(100)
.await
.map_err(|_| create_database_error!("find", COL))?;
Ok(ChunkedDatabaseGenerator::new_mongo(session, cursor))
}
// Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> {
query!(
@@ -180,13 +207,29 @@ impl AbstractChannels for MongoDb {
.map_err(|_| create_database_error!("update_one", "channels"))
}
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()> {
self.col::<Document>(COL)
.update_many(
doc! {
"_id": { "$in": channel_ids },
},
doc! {
"$pull": {
"recipients": user_id
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_many", COL))
}
// Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let id = channel.id().to_string();
let server_id = match channel {
Channel::TextChannel { server, .. } => {
Some(server)
}
Channel::TextChannel { server, .. } => Some(server),
_ => None,
};

View File

@@ -2,6 +2,7 @@ use std::collections::hash_map::Entry;
use super::AbstractChannels;
use crate::ReferenceDb;
use crate::util::ChunkedDatabaseGenerator;
use crate::{Channel, FieldsChannel, PartialChannel};
use revolt_permissions::OverrideField;
use revolt_result::Result;
@@ -51,6 +52,23 @@ impl AbstractChannels for ReferenceDb {
.collect())
}
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>> {
let channels = self.channels.lock().await;
let groups = channels
.values()
.filter(|channel| match channel {
Channel::Group { recipients, .. } => {
recipients.iter().any(|recipient| recipient == user_id)
}
_ => false,
})
.cloned()
.collect();
Ok(ChunkedDatabaseGenerator::new_reference(groups))
}
// Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> {
let channels = self.channels.lock().await;
@@ -131,9 +149,9 @@ impl AbstractChannels for ReferenceDb {
// Remove a user from a group
async fn remove_user_from_group(&self, channel: &str, user: &str) -> Result<()> {
let mut channels = self.channels.lock().await;
if let Some(channel_data) = channels.get_mut(channel) {
if channel_data.users()?.contains(&String::from(user)) {
channel_data.users()?.retain(|x| x != user);
if let Some(Channel::Group { recipients, .. }) = channels.get_mut(channel) {
if let Some(index) = recipients.iter().position(|recipient| recipient == user) {
recipients.remove(index);
return Ok(());
} else {
return Err(create_error!(NotFound));
@@ -142,6 +160,19 @@ impl AbstractChannels for ReferenceDb {
Err(create_error!(NotFound))
}
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()> {
let mut channels = self.channels.lock().await;
for channel_id in channel_ids {
if let Some(Channel::Group { recipients, .. }) = channels.get_mut(&channel_id) {
recipients.retain(|recipient| recipient != user_id);
}
};
Ok(())
}
// Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let mut channels = self.channels.lock().await;

View File

@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::str::FromStr;
use once_cell::sync::Lazy;
use revolt_models::v0;
use revolt_result::Result;
use ulid::Ulid;
@@ -11,7 +12,7 @@ use crate::Database;
static PERMISSIBLE_EMOJIS: Lazy<HashSet<String>> = Lazy::new(|| {
include_str!("unicode_emoji.txt")
.split('\n')
.map(|x| x.into())
.map(|x| x.replace('\u{FE0F}', ""))
.collect()
});
@@ -41,6 +42,12 @@ auto_derived!(
Server { id: String },
Detached,
}
/// Partial representation of an emoji
pub struct PartialEmoji {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
);
#[allow(clippy::disallowed_methods)]
@@ -75,13 +82,34 @@ impl Emoji {
db.detach_emoji(self).await
}
/// Update an emoji
pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> {
if let Some(name) = partial.name.clone() {
self.name = name;
}
db.update_emoji(&self.id, &partial).await?;
EventV1::EmojiUpdate {
id: self.id.clone(),
data: v0::PartialEmoji {
name: partial.name.clone(),
},
}
.p(self.parent().to_string())
.await;
Ok(())
}
/// Check whether we can use a given emoji
pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> {
if Ulid::from_str(emoji).is_ok() {
db.fetch_emoji(emoji).await?;
Ok(true)
} else {
Ok(PERMISSIBLE_EMOJIS.contains(emoji))
let sanitized_emoji = emoji.replace('\u{FE0F}', "");
Ok(PERMISSIBLE_EMOJIS.contains(&sanitized_emoji))
}
}
}

View File

@@ -1,6 +1,6 @@
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
#[cfg(feature = "mongodb")]
mod mongodb;
@@ -20,6 +20,9 @@ pub trait AbstractEmojis: Sync + Send {
/// Fetch emoji by their parent ids
async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result<Vec<Emoji>>;
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()>;
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>;
}

View File

@@ -1,7 +1,7 @@
use bson::Document;
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
use crate::MongoDb;
use super::AbstractEmojis;
@@ -46,6 +46,11 @@ impl AbstractEmojis for MongoDb {
)
}
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
query!(self, update_one_by_id, COL, emoji_id, partial, vec![], None).map(|_| ())
}
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
self.col::<Document>(COL)

View File

@@ -1,6 +1,6 @@
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
use crate::EmojiParent;
use crate::ReferenceDb;
@@ -54,6 +54,19 @@ impl AbstractEmojis for ReferenceDb {
.collect())
}
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
let mut emojis = self.emojis.lock().await;
if let Some(emoji) = emojis.get_mut(emoji_id) {
if let Some(name) = partial.name.clone() {
emoji.name = name;
}
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
let mut emojis = self.emojis.lock().await;

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,7 @@ auto_derived!(
LegacyGroupIcon,
ChannelIcon,
ServerIcon,
RoleIcon,
}
/// Information about what the file was used for
@@ -239,4 +240,23 @@ impl File {
)
.await
}
/// Use a file for a role icon
pub async fn use_role_icon(
db: &Database,
id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"icons",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::RoleIcon,
},
uploader_id.to_owned(),
)
.await
}
}

View File

@@ -50,4 +50,6 @@ pub trait AbstractMessages: Sync + Send {
author: &str,
since: SystemTime
) -> Result<HashMap<String, Vec<String>>>;
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()>;
}

View File

@@ -416,6 +416,12 @@ impl AbstractMessages for MongoDb {
Ok(deleted_messages)
}
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()> {
self.delete_bulk_messages(doc! {
"author": user_id,
}).await
}
}
impl IntoDocumentPath for FieldsMessage {

View File

@@ -1,10 +1,13 @@
use std::collections::HashMap;
use crate::{
AppendMessage, FieldsMessage, Message, MessageQuery,
PartialMessage, ReferenceDb,
};
use futures::future::try_join_all;
use indexmap::IndexSet;
use revolt_result::Result;
use std::collections::HashMap;
use std::time::SystemTime;
use ulid::Ulid;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages;
@@ -60,7 +63,7 @@ impl AbstractMessages for ReferenceDb {
if let Some(pinned) = query.filter.pinned {
if message.pinned.unwrap_or_default() == pinned {
return false
return false;
}
}
@@ -191,7 +194,12 @@ impl AbstractMessages for ReferenceDb {
}
/// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage, remove: Vec<FieldsMessage>) -> Result<()> {
async fn update_message(
&self,
id: &str,
message: &PartialMessage,
remove: Vec<FieldsMessage>,
) -> Result<()> {
let mut messages = self.messages.lock().await;
if let Some(message_data) = messages.get_mut(id) {
message_data.apply_options(message.to_owned());
@@ -294,7 +302,7 @@ impl AbstractMessages for ReferenceDb {
&self,
channels: &[String],
author: &str,
since: SystemTime
since: SystemTime,
) -> Result<HashMap<String, Vec<String>>> {
let threshold_ulid = Ulid::from_datetime(since).to_string();
let mut deleted_messages: HashMap<String, Vec<String>> = HashMap::new();
@@ -335,16 +343,23 @@ impl AbstractMessages for ReferenceDb {
}
// Delete the messages
self.messages
.lock()
.await
.retain(|id, message| {
let should_keep = !(message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str());
should_keep
});
self.messages.lock().await.retain(|id, message| {
let should_keep = !(message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str());
should_keep
});
Ok(deleted_messages)
}
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()> {
let mut messages = self.messages.lock().await;
messages.retain(|_, message| message.author != user_id);
// TODO: remove attachments as well
Ok(())
}
}

View File

@@ -0,0 +1,67 @@
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use revolt_result::{Error, Result};
use crate::{Database, MFATicket, UnvalidatedTicket, ValidatedTicket};
#[async_trait]
impl<S> FromRequestParts<S> for MFATicket
where
Database: FromRef<S>,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let db = Database::from_ref(state);
if let Some(Ok(token)) = parts.headers.get("x-mfa-ticket").map(|v| v.to_str()) {
db.fetch_ticket_by_token(token).await
} else {
Err(create_error!(MissingHeaders))
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for ValidatedTicket
where
Database: FromRef<S>,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let db = Database::from_ref(state);
let ticket = MFATicket::from_request_parts(parts, state).await?;
if ticket.validated && ticket.claim(&db).await.is_ok() {
Ok(ValidatedTicket(ticket))
} else {
Err(create_error!(InvalidToken))
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for UnvalidatedTicket
where
Database: FromRef<S>,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let ticket = MFATicket::from_request_parts(parts, state).await?;
if !ticket.validated {
Ok(UnvalidatedTicket(ticket))
} else {
Err(create_error!(InvalidToken))
}
}
}

View File

@@ -0,0 +1,11 @@
#[cfg(feature = "axum-impl")]
mod axum;
mod model;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
#[cfg(feature = "rocket-impl")]
mod schema;
pub use model::*;
pub use ops::*;

View File

@@ -0,0 +1,105 @@
use iso8601_timestamp::{Duration, Timestamp};
use std::ops::Deref;
use nanoid::nanoid;
use revolt_result::Result;
use crate::{Database, MultiFactorAuthentication};
auto_derived_partial!(
/// Multi-factor auth ticket
pub struct MFATicket {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Account Id
pub account_id: String,
/// Unique Token
pub token: String,
/// Whether this ticket has been validated
/// (can be used for account actions)
pub validated: bool,
/// Whether this ticket is authorised
/// (can be used to log a user in)
pub authorised: bool,
/// TOTP code at time of ticket creation
pub last_totp_code: Option<String>,
},
"PartialMFATicket"
);
/// Ticket which is guaranteed to be valid for use
///
/// If used in a Rocket guard, it will be consumed on match
#[derive(Debug, Serialize, Deserialize)]
pub struct ValidatedTicket(pub MFATicket);
/// Ticket which is guaranteed to not be valid for use
#[derive(Debug, Serialize, Deserialize)]
pub struct UnvalidatedTicket(pub MFATicket);
impl MFATicket {
/// Create a new MFA ticket
pub fn new(account_id: String, validated: bool) -> MFATicket {
MFATicket {
id: ulid::Ulid::new().to_string(),
account_id,
token: nanoid!(64),
validated,
authorised: false,
last_totp_code: None,
}
}
/// Populate an MFA ticket with valid MFA codes
pub async fn populate(&mut self, mfa: &MultiFactorAuthentication) {
self.last_totp_code = mfa.totp_token.generate_code().ok();
}
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_ticket(self).await
}
/// Check if this MFA ticket has expired
pub fn is_expired(&self) -> bool {
let now = Timestamp::now_utc();
let datetime: Timestamp = ulid::Ulid::from_string(&self.id)
.expect("Valid `ulid`")
.datetime()
.into();
now > (datetime.checked_add(Duration::minutes(5)).unwrap())
}
/// Claim and remove this MFA ticket
pub async fn claim(&self, db: &Database) -> Result<()> {
if self.is_expired() {
return Err(create_error!(InvalidToken));
}
db.delete_ticket(&self.id).await
}
}
impl Deref for ValidatedTicket {
type Target = MFATicket;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Deref for UnvalidatedTicket {
type Target = MFATicket;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@@ -0,0 +1,22 @@
use revolt_result::Result;
use crate::MFATicket;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractMFATickets: Sync + Send {
/// Find ticket by token
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket>;
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()>;
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()>;
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize>;
}

View File

@@ -0,0 +1,67 @@
use std::time::{Duration, SystemTime};
use crate::{AbstractMFATickets, MFATicket, MongoDb};
use bson::{to_document, Document};
use iso8601_timestamp::Timestamp;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
use ulid::Ulid;
const COL: &str = "mfa_tickets";
#[async_trait]
impl AbstractMFATickets for MongoDb {
/// Find ticket by token
///
/// Ticket is only valid for 5 minute
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket> {
let ticket: MFATicket = query!(self, find_one, COL, doc! { "token": token })?
.ok_or_else(|| create_error!(InvalidToken))?;
if let Ok(ulid) = Ulid::from_string(&ticket.id) {
if Timestamp::from(ulid.datetime() + Duration::from_mins(5)) > Timestamp::now_utc() {
Ok(ticket)
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()> {
self.col::<MFATicket>(COL)
.update_one(
doc! {
"_id": &ticket.id
},
doc! {
"$set": to_document(ticket).map_err(|_| create_database_error!("to_document", COL))?,
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("upsert_one", COL))
.map(|_| ())
}
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ())
}
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize> {
let threshhold =
Ulid::from_datetime(SystemTime::now() - Duration::from_mins(5)).to_string();
self.col::<Document>(COL)
.delete_many(doc! {
"_id": { "$lt": threshhold }
})
.await
.map_err(|_| create_database_error!("delete_many", COL))
.map(|result| result.deleted_count as usize)
}
}

View File

@@ -0,0 +1,57 @@
use std::time::{Duration, SystemTime};
use crate::{AbstractMFATickets, MFATicket, ReferenceDb};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use ulid::Ulid;
#[async_trait]
impl AbstractMFATickets for ReferenceDb {
/// Find ticket by token
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket> {
let tickets = self.tickets.lock().await;
let ticket = tickets
.values()
.find(|ticket| ticket.token == token)
.ok_or_else(|| create_error!(InvalidToken))?;
if let Ok(ulid) = Ulid::from_string(&ticket.id) {
if Timestamp::from(ulid.datetime() + Duration::from_mins(5)) > Timestamp::now_utc() {
Ok(ticket.clone())
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()> {
let mut tickets = self.tickets.lock().await;
tickets.insert(ticket.id.to_string(), ticket.clone());
Ok(())
}
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()> {
let mut tickets = self.tickets.lock().await;
if tickets.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(InvalidToken))
}
}
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize> {
let threshhold =
Ulid::from_datetime(SystemTime::now() - Duration::from_mins(5)).to_string();
let mut tickets = self.tickets.lock().await;
let before = tickets.len();
tickets.retain(|_, ticket| ticket.id >= threshhold);
Ok(before - tickets.len())
}
}

View File

@@ -0,0 +1,81 @@
use crate::{Database, MFATicket, UnvalidatedTicket, ValidatedTicket};
use revolt_result::Error;
use rocket::{
http::Status,
outcome::Outcome,
request::{self, FromRequest},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for MFATicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
if let Some(header_mfa_ticket) = request.headers().get("x-mfa-ticket").next() {
if let Ok(ticket) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_ticket_by_token(header_mfa_ticket)
.await
{
Outcome::Success(ticket)
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidToken)))
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(MissingHeaders)))
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ValidatedTicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match request.guard::<MFATicket>().await {
Outcome::Success(ticket) => {
if ticket.validated {
let db = request
.rocket()
.state::<Database>()
.expect("`Database`");
if ticket.claim(db).await.is_ok() {
Outcome::Success(ValidatedTicket(ticket))
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
}
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Error(err) => Outcome::Error(err),
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for UnvalidatedTicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match request.guard::<MFATicket>().await {
Outcome::Success(ticket) => {
if !ticket.validated {
Outcome::Success(UnvalidatedTicket(ticket))
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
}
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Error(err) => Outcome::Error(err),
}
}
}

View File

@@ -0,0 +1,80 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::{MFATicket, ValidatedTicket, UnvalidatedTicket};
impl<'r> OpenApiFromRequest<'r> for MFATicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}
impl<'r> OpenApiFromRequest<'r> for ValidatedTicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Valid MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Valid MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}
impl<'r> OpenApiFromRequest<'r> for UnvalidatedTicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Unvalidated MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Unvalidated MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -18,6 +18,10 @@ mod server_members;
mod servers;
mod user_settings;
mod users;
mod accounts;
mod account_invites;
mod sessions;
mod mfa_tickets;
pub use admin_migrations::*;
pub use audit_logs::*;
@@ -39,6 +43,10 @@ pub use server_members::*;
pub use servers::*;
pub use user_settings::*;
pub use users::*;
pub use accounts::*;
pub use account_invites::*;
pub use sessions::*;
pub use mfa_tickets::*;
use crate::{Database, ReferenceDb};
@@ -68,6 +76,10 @@ pub trait AbstractDatabase:
+ servers::AbstractServers
+ user_settings::AbstractUserSettings
+ users::AbstractUsers
+ accounts::AbstractAccounts
+ account_invites::AbstractAccountInvites
+ sessions::AbstractSessions
+ mfa_tickets::AbstractMFATickets
{
}

View File

@@ -28,6 +28,9 @@ auto_derived_partial!(
/// Member's nickname
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
/// Member's pronouns
#[serde(skip_serializing_if = "Option::is_none")]
pub pronouns: Option<String>,
/// Avatar attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<File>,
@@ -65,6 +68,7 @@ auto_derived!(
/// Optional fields on server member object
pub enum FieldsMember {
Nickname,
Pronouns,
Avatar,
Roles,
Timeout,
@@ -88,6 +92,7 @@ impl Default for Member {
id: Default::default(),
joined_at: Timestamp::now_utc(),
nickname: None,
pronouns: None,
avatar: None,
roles: vec![],
timeout: None,
@@ -231,6 +236,7 @@ impl Member {
FieldsMember::JoinedAt => {}
FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None,
FieldsMember::Pronouns => self.pronouns = None,
FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true,

View File

@@ -129,4 +129,7 @@ pub trait AbstractServerMembers: Sync + Send {
/// Fetch all members who have been marked for deletion.
async fn remove_dangling_members(&self) -> Result<()>;
/// Removes a user from every server they are in
async fn clear_memberships(&self, user_id: &str) -> Result<()>;
}

View File

@@ -326,6 +326,17 @@ impl AbstractServerMembers for MongoDb {
.map(|_| ())
.map_err(|_| create_database_error!("count_documents", COL))
}
/// Removes a user from every server they are in
///
/// **This should only be used for account deletion.**
async fn clear_memberships(&self, user_id: &str) -> Result<()> {
self.col::<Member>(COL)
.delete_many(doc! { "_id.user": user_id })
.await
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
}
impl IntoDocumentPath for FieldsMember {
@@ -334,6 +345,7 @@ impl IntoDocumentPath for FieldsMember {
FieldsMember::JoinedAt => Some("joined_at"),
FieldsMember::Avatar => Some("avatar"),
FieldsMember::Nickname => Some("nickname"),
FieldsMember::Pronouns => Some("pronouns"),
FieldsMember::Roles => Some("roles"),
FieldsMember::Timeout => Some("timeout"),
FieldsMember::CanPublish => Some("can_publish"),

View File

@@ -200,4 +200,13 @@ impl AbstractServerMembers for ReferenceDb {
async fn remove_dangling_members(&self) -> Result<()> {
todo!()
}
/// Removes a user from every server they are in
async fn clear_memberships(&self, user_id: &str) -> Result<()> {
let mut server_members = self.server_members.lock().await;
server_members.retain(|_, v| v.id.user != user_id);
Ok(())
}
}

View File

@@ -86,6 +86,9 @@ auto_derived_partial!(
/// Ranking of this role
#[serde(default)]
pub rank: i64,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
},
"PartialRole"
);
@@ -129,6 +132,7 @@ auto_derived!(
/// Optional fields on server object
pub enum FieldsRole {
Colour,
Icon,
}
);
@@ -330,6 +334,7 @@ impl Role {
colour: self.colour,
hoist: Some(self.hoist),
rank: Some(self.rank),
icon: self.icon,
}
}
@@ -343,6 +348,7 @@ impl Role {
colour: None,
hoist: false,
permissions: Default::default(),
icon: None,
};
db.insert_role(&server.id, &role).await?;
@@ -392,6 +398,7 @@ impl Role {
pub fn remove_field(&mut self, field: &FieldsRole) {
match field {
FieldsRole::Colour => self.colour = None,
FieldsRole::Icon => self.icon = None,
}
}

View File

@@ -17,6 +17,8 @@ pub trait AbstractServers: Sync + Send {
/// Fetch a servers by their ids
async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result<Vec<Server>>;
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>>;
/// Update a server with new information
async fn update_server(
&self,

View File

@@ -43,6 +43,17 @@ impl AbstractServers for MongoDb {
.await)
}
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>> {
query!(
self,
find,
COL,
doc! {
"owner": user_id
}
)
}
/// Update a server with new information
async fn update_server(
&self,
@@ -77,7 +88,7 @@ impl AbstractServers for MongoDb {
},
doc! {
"$set": {
"roles.".to_owned() + &role.id: to_document(role)
"roles.".to_owned() + role.id.as_str(): to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))?
}
},
@@ -172,6 +183,7 @@ impl IntoDocumentPath for FieldsRole {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
})
}
}

View File

@@ -40,6 +40,16 @@ impl AbstractServers for ReferenceDb {
.collect()
}
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>> {
let servers = self.servers.lock().await;
Ok(servers
.values()
.filter(|server| server.owner == user_id)
.cloned()
.collect())
}
/// Update a server with new information
async fn update_server(
&self,

View File

@@ -0,0 +1,24 @@
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{create_error, Error, Result};
use crate::{Database, Session};
#[async_trait]
impl<S> FromRequestParts<S> for Session
where
Database: FromRef<S>,
S: Send + Sync
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let db = Database::from_ref(state);
if let Some(Ok(token)) = parts.headers.get("x-session-token").map(|v| v.to_str()) {
db.fetch_session_by_token(token).await
} else {
Err(create_error!(MissingHeaders))
}
}
}

View File

@@ -0,0 +1,11 @@
#[cfg(feature = "axum-impl")]
mod axum;
mod model;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
#[cfg(feature = "rocket-impl")]
mod schema;
pub use model::*;
pub use ops::*;

View File

@@ -0,0 +1,68 @@
use iso8601_timestamp::Timestamp;
use crate::{events::client::EventV1, Database};
use revolt_result::Result;
auto_derived_partial!(
/// Session information
pub struct Session {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// User Id
pub user_id: String,
/// Session token
pub token: String,
/// Display name
pub name: String,
/// When the session was last logged in
pub last_seen: Timestamp,
/// Where the session originated from
///
/// This could be used to differentiate sessions that come from staging/test vs prod, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
/// Web Push subscription
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription: Option<WebPushSubscription>,
},
"PartialSession"
);
auto_derived!(
/// Web Push subscription
pub struct WebPushSubscription {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
);
impl Session {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_session(self).await
}
/// Delete session
pub async fn delete(self, db: &Database) -> Result<()> {
// Delete from database
db.delete_session(&self.id).await?;
// Create and push event
EventV1::DeleteSession {
user_id: self.user_id.clone(),
session_id: self.id,
}
.private(self.user_id)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,37 @@
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::Session;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractSessions: Sync + Send {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session>;
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>>;
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>>;
/// Find session by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session>;
/// Save session
async fn save_session(&self, session: &Session) -> Result<()>;
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()>;
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()>;
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>;
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()>;
}

View File

@@ -0,0 +1,142 @@
use crate::{AbstractSessions, MongoDb, Session};
use bson::{to_bson, to_document};
use iso8601_timestamp::Timestamp;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
const COL: &str = "sessions";
#[async_trait]
impl AbstractSessions for MongoDb {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(UnknownUser))
}
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>> {
query!(
self,
find,
COL,
doc! {
"user_id": user_id
}
)
}
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>> {
query!(
self,
find,
COL,
doc! {
"user_id": {
"$in": user_ids
},
"subscription": {
"$exists": true
}
}
)
}
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
query!(
self,
find_one,
COL,
doc! {
"token": token
}
)?
.ok_or_else(|| create_error!(InvalidSession))
}
/// Save session
async fn save_session(&self, session: &Session) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": &session.id
},
doc! {
"$set": to_document(session).map_err(|_| create_database_error!("to_document", COL))?,
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("upsert_one", COL))
.map(|_| ())
}
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()> {
self.col::<Session>(COL)
.delete_one(doc! {
"_id": id
})
.await
.map_err(|_| create_database_error!("delete_one", COL))
.map(|_| ())
}
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()> {
let mut query = doc! {
"user_id": user_id
};
if let Some(id) = ignore {
query.insert(
"_id",
doc! {
"$ne": id
},
);
}
self.col::<Session>(COL)
.delete_many(query)
.await
.map_err(|_| create_database_error!("delete_one", COL))
.map(|_| ())
}
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": session_id
},
doc! {
"$unset": {
"subscription": 1
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": session_id
},
doc! {
"$set": {
"last_seen": to_bson(&when).unwrap()
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
}

View File

@@ -0,0 +1,101 @@
use crate::{AbstractSessions, ReferenceDb, Session};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
#[async_trait]
impl AbstractSessions for ReferenceDb {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session> {
let sessions = self.sessions.lock().await;
sessions
.get(id)
.cloned()
.ok_or_else(|| create_error!(UnknownUser))
}
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.filter(|session| session.user_id == user_id)
.cloned()
.collect())
}
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.filter(|session| session.subscription.is_some() && user_ids.contains(&session.user_id))
.cloned()
.collect())
}
/// Find session by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
let sessions = self.sessions.lock().await;
sessions
.values()
.find(|session| session.token == token)
.cloned()
.ok_or_else(|| create_error!(InvalidSession))
}
/// Save session
async fn save_session(&self, session: &Session) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(session.id.to_string(), session.clone());
Ok(())
}
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if sessions.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(InvalidSession))
}
}
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.retain(|_, session| {
if session.user_id == user_id {
if let Some(ignore) = &ignore {
ignore == &session.id
} else {
false
}
} else {
true
}
});
Ok(())
}
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
session.subscription = None;
};
Ok(())
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
session.last_seen = when;
};
Ok(())
}
}

View File

@@ -0,0 +1,30 @@
use crate::{Database, Session};
use revolt_result::Error;
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Session {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
if let Some(token) = request.headers().get("x-session-token").next() {
if let Ok(session) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_session_by_token(token)
.await
{
Outcome::Success(session)
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(MissingHeaders)))
}
}
}

View File

@@ -0,0 +1,32 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::Session;
impl<'r> OpenApiFromRequest<'r> for Session {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Session Token".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Session Token".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-session-token".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authenticate as a user.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -1,12 +1,16 @@
use std::{collections::HashSet, str::FromStr, time::Duration};
use crate::{events::client::EventV1, Database, File, RatelimitEvent, AMQP};
use crate::{
events::client::EventV1,
util::email::{email_templates, send_email},
Database, File, RatelimitEvent, AMQP,
};
use authifier::config::{EmailVerificationConfig, Template};
use futures::future::join_all;
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy;
use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online;
@@ -27,6 +31,9 @@ auto_derived_partial!(
/// Display name
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// User's pronouns
#[serde(skip_serializing_if = "Option::is_none", default)]
pub pronouns: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Avatar attachment
pub avatar: Option<File>,
@@ -72,6 +79,7 @@ auto_derived!(
ProfileContent,
ProfileBackground,
DisplayName,
Pronouns,
// internal fields
Suspension,
@@ -163,6 +171,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect()
});
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
.case_insensitive(true)
.build()
.unwrap()
});
#[allow(clippy::derivable_impls)]
impl Default for User {
fn default() -> Self {
@@ -171,6 +186,7 @@ impl Default for User {
username: Default::default(),
discriminator: Default::default(),
display_name: Default::default(),
pronouns: Default::default(),
avatar: Default::default(),
relations: Default::default(),
badges: Default::default(),
@@ -198,11 +214,13 @@ impl User {
I: Into<Option<String>>,
D: Into<Option<PartialUser>>,
{
let username = User::validate_username(username)?;
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &username, None).await?,
username,
discriminator: User::find_discriminator(db, &new_username, None).await?,
username: new_username.clone(),
last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default()
};
@@ -278,39 +296,40 @@ impl User {
}
}
/// Sanitise and validate a username can be used
pub fn validate_username(username: String) -> Result<String> {
// Copy the username for validation
/// Validate a username
///
/// This will check if the username is a blocked name or contains a blocked pattern.
fn validate_username(username: &str) -> Result<()> {
let username_lowercase = username.to_lowercase();
// Block homoglyphs
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
return Err(create_error!(InvalidUsername));
}
// Ensure the username itself isn't blocked
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"];
Ok(())
}
for username in BLOCKED_USERNAMES {
if username_lowercase == *username {
return Err(create_error!(InvalidUsername));
}
}
/// Sanitise a username
///
/// This will clean up Unicode homoglyphs and pad to the min username length with underscores.
async fn sanitise_username(username: &str) -> Result<String> {
let options = decancer::Options::default().retain_capitalization();
let mut username = decancer::cure(username, options)
.map_err(|_| create_error!(InvalidUsername))?
.to_string();
// Ensure none of the following substrings show up in the username
const BLOCKED_SUBSTRINGS: &[&str] = &[
"```",
"discord.gg",
"rvlt.gg",
"guilded.gg",
"https://",
"http://",
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
let config = revolt_config::config().await;
let username_length_diff = config
.api
.users
.min_username_length
.saturating_sub(username.len());
if username_length_diff > 0 {
username.push_str(&"_".repeat(username_length_diff))
}
Ok(username)
@@ -416,12 +435,14 @@ impl User {
/// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let username = User::validate_username(username)?;
if self.username.to_lowercase() == username.to_lowercase() {
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
if self.username.to_lowercase() == new_username.to_lowercase() {
self.update(
db,
PartialUser {
username: Some(username),
username: Some(new_username),
..Default::default()
},
vec![],
@@ -434,12 +455,12 @@ impl User {
discriminator: Some(
User::find_discriminator(
db,
&username,
&new_username,
Some((self.discriminator.to_string(), self.id.clone())),
)
.await?,
),
username: Some(username),
username: Some(new_username),
..Default::default()
},
vec![],
@@ -690,6 +711,7 @@ impl User {
}
}
FieldsUser::DisplayName => self.display_name = None,
FieldsUser::Pronouns => self.pronouns = None,
FieldsUser::Suspension => self.suspended_until = None,
FieldsUser::None => {}
}
@@ -705,22 +727,11 @@ impl User {
duration_days: Option<usize>,
reason: Option<Vec<String>>,
) -> Result<()> {
let authifier = db.clone().to_authifier().await;
let mut account = authifier
.database
.find_account(&self.id)
.await
.map_err(|_| create_error!(InternalError))?;
let mut account = db.fetch_account(&self.id).await?;
account
.disable(&authifier)
.await
.map_err(|_| create_error!(InternalError))?;
account.disable(db).await?;
account
.delete_all_sessions(&authifier, None)
.await
.map_err(|_| create_error!(InternalError))?;
account.delete_all_sessions(db, None).await?;
self.update(
db,
@@ -736,18 +747,15 @@ impl User {
.await?;
if let Some(reason) = reason {
if let EmailVerificationConfig::Enabled { smtp, .. } =
authifier.config.email_verification
{
smtp.send_email(
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
send_email(
&config.api.smtp,
account.email.clone(),
// maybe move this to common area?
&Template {
title: "Account Suspension".to_string(),
html: Some(include_str!("../../../templates/suspension.html").to_owned()),
text: include_str!("../../../templates/suspension.txt").to_owned(),
url: Default::default(),
},
&templates.suspension,
json!({
"email": account.email,
"list": reason.join(", "),
@@ -796,7 +804,9 @@ impl User {
db,
PartialUser {
username: Some(format!("Deleted User {}", self.id)),
discriminator: Some("0000".to_string()),
flags: Some(2),
relations: Some(Vec::new()),
..Default::default()
},
vec![
@@ -824,4 +834,160 @@ impl User {
badges
}
/// Removes all relationships which include the user
pub async fn clear_relationships(&self, db: &Database) -> Result<()> {
let user_ids = self
.relations
.iter()
.flatten()
.map(|relation| relation.id.clone())
.collect();
db.clear_user_relationships(&self.id, user_ids).await
}
/// Removes user from all joined groups
pub async fn remove_from_all_groups(&self, db: &Database) -> Result<()> {
let mut generator = db.find_group_message_channels(&self.id).await?;
while let Some(groups) = generator.next_n(100).await? {
let ids = groups
.into_iter()
.map(|channel| channel.id().to_string())
.collect();
db.remove_user_from_groups(ids, &self.id).await?;
}
Ok(())
}
/// Deletes the user along with:
/// - deletes owned bots, servers and messages
/// - removes user from all groups
/// - clears relationships
pub async fn delete(&mut self, db: &Database) -> Result<()> {
for bot in db.fetch_bots_by_user(&self.id).await? {
bot.delete(db).await?;
}
for server in db.fetch_owned_servers(&self.id).await? {
server.delete(db).await?;
}
self.remove_from_all_groups(db).await?;
db.clear_memberships(&self.id).await?;
self.clear_relationships(db).await?;
db.delete_messages_by_user(&self.id).await?;
self.mark_deleted(db).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::User;
#[test]
fn username_validation_blocked_names() {
let username_admin = "Admin";
let username_revolt = "Revolt";
let username_stoat = "Stoat";
let username_allowed = "Allowed";
assert!(User::validate_username(username_admin).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_allowed).is_ok());
}
#[test]
fn username_validation_blocked_patterns() {
let username_grave = "```_test";
let username_discord = "discord.gg_test";
let username_rvlt = "rvlt.gg_test";
let username_guilded = "guilded.gg_test";
let username_stt = "stt.gg_test";
let username_revolt = "revolt.chat_test";
let username_stoat = "stoat.chat_test";
let username_http = "http://_test";
let username_https = "https://_test";
assert!(User::validate_username(username_grave).is_err());
assert!(User::validate_username(username_discord).is_err());
assert!(User::validate_username(username_rvlt).is_err());
assert!(User::validate_username(username_guilded).is_err());
assert!(User::validate_username(username_stt).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_http).is_err());
assert!(User::validate_username(username_https).is_err());
}
#[async_std::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
let username_clean_sanitised = User::sanitise_username(username_clean).await;
assert!(username_clean_sanitised.is_ok());
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[async_std::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
let username_homoglyphs_sanitised =
User::sanitise_username(username_homoglyphs).await.unwrap();
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[async_std::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
let username = User::sanitise_username(username_padding).await.unwrap();
assert_eq!("a_", username);
}
#[async_std::test]
async fn create_user() {
use revolt_result::Result;
database_test!(|db| async move {
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
assert_eq!("Test", created_clean.username);
created_clean
.update_username(&db, "Test2".to_string())
.await
.unwrap();
assert_eq!("Test2", created_clean.username);
let created_invalid_result: Result<_> =
User::create(&db, "stoat.chat".to_string(), None, None).await;
assert!(created_invalid_result.is_err());
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
let updated_invalid_update_result = updated_invalid
.update_username(&db, "http://test".to_string())
.await;
assert!(updated_invalid_update_result.is_err());
});
}
}

View File

@@ -1,5 +1,3 @@
use authifier::models::Session;
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -19,9 +17,6 @@ pub trait AbstractUsers: Sync + Send {
/// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>;
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session>;
/// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>;
@@ -61,8 +56,6 @@ pub trait AbstractUsers: Sync + Send {
/// Delete a user by their id
async fn delete_user(&self, id: &str) -> Result<()>;
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>;
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()>;
/// Removes all relationships with the user from the list of users
async fn clear_user_relationships(&self, target_id: &str, user_ids: Vec<String>) -> Result<()>;
}

View File

@@ -1,7 +1,5 @@
use ::mongodb::options::{Collation, CollationStrength, FindOneOptions, FindOptions};
use authifier::models::Session;
use futures::StreamExt;
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::DocumentId;
@@ -47,17 +45,6 @@ impl AbstractUsers for MongoDb {
.ok_or_else(|| create_error!(NotFound))
}
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
self.col::<Session>("sessions")
.find_one(doc! {
"token": token
})
.await
.map_err(|_| create_database_error!("find_one", "sessions"))?
.ok_or_else(|| create_error!(InvalidSession))
}
/// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> {
Ok(self
@@ -321,41 +308,22 @@ impl AbstractUsers for MongoDb {
query!(self, delete_one_by_id, COL, id).map(|_| ())
}
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
self.col::<User>("sessions")
.update_one(
/// Removes all relationships with the user from the list of users
async fn clear_user_relationships(&self, target_id: &str, user_ids: Vec<String>) -> Result<()> {
self.col::<User>(COL)
.update_many(
doc! { "_id": { "$in": user_ids } },
doc! {
"_id": session_id
},
doc! {
"$unset": {
"subscription": 1
"$pull": {
"relations": {
"_id": target_id.to_string()
}
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", "sessions"))
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
let formatted: &str = &when.format();
self.col::<Session>("sessions")
.update_one(
doc! {
"_id": session_id
},
doc! {
"$set": {
"last_seen": formatted
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", "sessions"))
.map_err(|_| create_database_error!("bulk_write", COL))
}
}
@@ -368,6 +336,7 @@ impl IntoDocumentPath for FieldsUser {
FieldsUser::StatusPresence => "status.presence",
FieldsUser::StatusText => "status.text",
FieldsUser::DisplayName => "display_name",
FieldsUser::Pronouns => "pronouns",
FieldsUser::Suspension => "suspended_until",
FieldsUser::None => "none",
})

View File

@@ -1,5 +1,3 @@
use authifier::models::Session;
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -42,11 +40,6 @@ impl AbstractUsers for ReferenceDb {
.ok_or_else(|| create_error!(NotFound))
}
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, _token: &str) -> Result<Session> {
todo!()
}
/// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> {
let users = self.users.lock().await;
@@ -165,12 +158,22 @@ impl AbstractUsers for ReferenceDb {
}
}
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, _session_id: &str) -> Result<()> {
todo!()
}
/// Removes all relationships with the user from the list of users
async fn clear_user_relationships(
&self,
target_id: &str,
user_ids: Vec<String>,
) -> Result<()> {
let mut users = self.users.lock().await;
async fn update_session_last_seen(&self, _session_id: &str, _when: Timestamp) -> Result<()> {
todo!()
for user_id in user_ids {
if let Some(user) = users.get_mut(&user_id) {
if let Some(relations) = &mut user.relations {
relations.retain(|relation| relation.id != target_id);
}
}
}
Ok(())
}
}

View File

@@ -1,12 +1,12 @@
use authifier::models::Session;
use rocket::http::Status;
use rocket::request::{self, FromRequest, Outcome, Request};
use revolt_result::Error;
use crate::{Database, User};
use crate::{Database, Session, User};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for User {
type Error = authifier::Error;
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let user: &Option<User> = request
@@ -38,7 +38,7 @@ impl<'r> FromRequest<'r> for User {
if let Some(user) = user {
Outcome::Success(user.clone())
} else {
Outcome::Error((Status::Unauthorized, authifier::Error::InvalidSession))
Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
}
}
}

View File

@@ -105,7 +105,11 @@ pub async fn handle_ack_event(
if mentions_acked > 0 {
if let Err(err) = amqp
.ack_message(user.to_string(), channel.to_string(), id.to_owned())
.ack_notification_message(
user.to_string(),
channel.to_string(),
id.to_owned(),
)
.await
{
revolt_config::capture_error(&err);
@@ -192,9 +196,7 @@ pub async fn handle_ack_event(
.expect("Failed to fetch channel from db");
if let TextChannel { server, .. } = channel {
if let Err(err) =
amqp.mass_mention_message_sent(server, mass_mentions).await
{
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await {
revolt_config::capture_error(&err);
}
} else {

View File

@@ -1,29 +0,0 @@
use async_std::channel::{unbounded, Receiver, Sender};
use authifier::AuthifierEvent;
use once_cell::sync::Lazy;
use crate::events::client::EventV1;
static Q: Lazy<(Sender<AuthifierEvent>, Receiver<AuthifierEvent>)> = Lazy::new(unbounded);
/// Get sender
pub fn sender() -> Sender<AuthifierEvent> {
Q.0.clone()
}
/// Start a new worker
pub async fn worker() {
loop {
let event = Q.1.recv().await.unwrap();
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
}
}
}
}

View File

@@ -8,14 +8,11 @@ use std::time::Instant;
const WORKER_COUNT: usize = 5;
pub mod ack;
pub mod authifier_relay;
pub mod last_message_id;
pub mod process_embeds;
/// Spawn background workers
pub fn start_workers(db: Database, amqp: AMQP) {
task::spawn(authifier_relay::worker());
for _ in 0..WORKER_COUNT {
task::spawn(ack::worker(db.clone(), amqp.clone()));
task::spawn(last_message_id::worker(db.clone()));

View File

@@ -0,0 +1,77 @@
use redis_kiss::{get_connection, AsyncCommands};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{Result, ToRevoltError};
use crate::{events::client::EventV1, Channel, Database, Server, User, AMQP};
pub async fn ack_channel(user: &str, channel: &str, message: &str, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let old: Option<String> = redis
.getset(format!("acker:{user}+{channel}"), message)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == message {
amqp.process_ack(user, Some(channel), None)
.await
.to_internal_error()?;
}
Ok(())
}
pub async fn ack_server(user: &User, server: &Server, db: &Database, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let channels = db.fetch_channels(&server.channels).await?;
let query = crate::util::permissions::DatabasePermissionQuery::new(db, user).server(server);
for channel in channels {
let channel_id = channel.id();
let mut q = query.clone().channel(&channel);
if calculate_channel_permissions(&mut q)
.await
.has_channel_permission(ChannelPermission::ViewChannel)
{
let channel_last_msg = match &channel {
Channel::TextChannel {
last_message_id, ..
} => last_message_id,
_ => unreachable!(),
}
.clone();
if let Some(channel_last_msg) = channel_last_msg {
let old: Option<String> = redis
.getset(
format!("acker:{}+{}", user.id, channel_id),
&channel_last_msg,
)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == channel_last_msg {
amqp.process_ack(&user.id, Some(channel_id), Some(&server.id))
.await
.to_internal_error()?;
EventV1::ChannelAck {
id: channel_id.to_string(),
user: user.id.clone(),
message_id: channel_last_msg,
}
.private(user.id.clone())
.await;
}
}
}
}
Ok(())
}

View File

@@ -631,6 +631,7 @@ impl From<crate::Member> for Member {
id: value.id.into(),
joined_at: value.joined_at,
nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
@@ -646,6 +647,7 @@ impl From<Member> for crate::Member {
id: value.id.into(),
joined_at: value.joined_at,
nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
@@ -661,6 +663,7 @@ impl From<crate::PartialMember> for PartialMember {
id: value.id.map(|id| id.into()),
joined_at: value.joined_at,
nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
@@ -676,6 +679,7 @@ impl From<PartialMember> for crate::PartialMember {
id: value.id.map(|id| id.into()),
joined_at: value.joined_at,
nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
@@ -708,6 +712,7 @@ impl From<crate::FieldsMember> for FieldsMember {
match value {
crate::FieldsMember::Avatar => FieldsMember::Avatar,
crate::FieldsMember::Nickname => FieldsMember::Nickname,
crate::FieldsMember::Pronouns => FieldsMember::Pronouns,
crate::FieldsMember::Roles => FieldsMember::Roles,
crate::FieldsMember::Timeout => FieldsMember::Timeout,
crate::FieldsMember::CanReceive => FieldsMember::CanReceive,
@@ -723,6 +728,7 @@ impl From<FieldsMember> for crate::FieldsMember {
match value {
FieldsMember::Avatar => crate::FieldsMember::Avatar,
FieldsMember::Nickname => crate::FieldsMember::Nickname,
FieldsMember::Pronouns => crate::FieldsMember::Pronouns,
FieldsMember::Roles => crate::FieldsMember::Roles,
FieldsMember::Timeout => crate::FieldsMember::Timeout,
FieldsMember::CanReceive => crate::FieldsMember::CanReceive,
@@ -926,6 +932,7 @@ impl From<crate::Role> for Role {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -939,6 +946,7 @@ impl From<Role> for crate::Role {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -952,6 +960,7 @@ impl From<crate::PartialRole> for PartialRole {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -965,6 +974,7 @@ impl From<PartialRole> for crate::PartialRole {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -973,6 +983,7 @@ impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self {
match value {
crate::FieldsRole::Colour => FieldsRole::Colour,
crate::FieldsRole::Icon => FieldsRole::Icon,
}
}
}
@@ -981,6 +992,7 @@ impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self {
match value {
FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
}
}
}
@@ -1026,6 +1038,7 @@ impl crate::User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()),
relations: if let Some(crate::User { id, .. }) = perspective {
if id == &self.id {
@@ -1102,6 +1115,7 @@ impl crate::User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
badges,
@@ -1135,6 +1149,7 @@ impl crate::User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
badges,
@@ -1162,6 +1177,7 @@ impl crate::User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()),
relations: self
.relations
@@ -1205,6 +1221,7 @@ impl From<User> for crate::User {
username: value.username,
discriminator: value.discriminator,
display_name: value.display_name,
pronouns: value.pronouns,
avatar: value.avatar.map(Into::into),
relations: None,
badges: Some(value.badges as i32),
@@ -1225,6 +1242,7 @@ impl From<crate::PartialUser> for PartialUser {
username: value.username,
discriminator: value.discriminator,
display_name: value.display_name,
pronouns: value.pronouns,
avatar: value.avatar.map(|file| file.into()),
relations: value.relations.map(|relationships| {
relationships
@@ -1253,6 +1271,7 @@ impl From<FieldsUser> for crate::FieldsUser {
FieldsUser::StatusPresence => crate::FieldsUser::StatusPresence,
FieldsUser::StatusText => crate::FieldsUser::StatusText,
FieldsUser::DisplayName => crate::FieldsUser::DisplayName,
FieldsUser::Pronouns => crate::FieldsUser::Pronouns,
FieldsUser::Internal => crate::FieldsUser::None,
}
@@ -1268,6 +1287,7 @@ impl From<crate::FieldsUser> for FieldsUser {
crate::FieldsUser::StatusPresence => FieldsUser::StatusPresence,
crate::FieldsUser::StatusText => FieldsUser::StatusText,
crate::FieldsUser::DisplayName => FieldsUser::DisplayName,
crate::FieldsUser::Pronouns => FieldsUser::Pronouns,
crate::FieldsUser::Suspension => FieldsUser::Internal,
crate::FieldsUser::None => FieldsUser::Internal,
@@ -1556,3 +1576,92 @@ impl From<crate::AuditLogEntry> for AuditLogEntry {
}
}
}
impl From<crate::Account> for AccountInfo {
fn from(item: crate::Account) -> Self {
AccountInfo {
id: item.id,
email: item.email,
}
}
}
impl From<crate::MFATicket> for MFATicket {
fn from(value: crate::MFATicket) -> Self {
MFATicket {
id: value.id,
account_id: value.account_id,
token: value.token,
validated: value.validated,
authorised: value.authorised,
last_totp_code: value.last_totp_code,
}
}
}
impl From<crate::MultiFactorAuthentication> for MultiFactorStatus {
fn from(item: crate::MultiFactorAuthentication) -> Self {
MultiFactorStatus {
// email_otp: item.enable_email_otp,
// trusted_handover: item.enable_trusted_handover,
// email_mfa: item.enable_email_mfa,
totp_mfa: !item.totp_token.is_disabled(),
// security_key_mfa: item.security_key_token.is_some(),
recovery_active: !item.recovery_codes.is_empty(),
..Default::default()
}
}
}
impl From<crate::MFAMethod> for MFAMethod {
fn from(value: crate::MFAMethod) -> Self {
match value {
crate::MFAMethod::Password => MFAMethod::Password,
crate::MFAMethod::Recovery => MFAMethod::Recovery,
crate::MFAMethod::Totp => MFAMethod::Totp,
}
}
}
impl From<crate::Session> for SessionInfo {
fn from(item: crate::Session) -> Self {
SessionInfo {
id: item.id,
name: item.name,
}
}
}
impl From<crate::Session> for Session {
fn from(value: crate::Session) -> Self {
Session {
id: value.id,
user_id: value.user_id,
token: value.token,
name: value.name,
last_seen: value.last_seen,
origin: value.origin,
subscription: value.subscription.map(Into::into),
}
}
}
impl From<crate::WebPushSubscription> for WebPushSubscription {
fn from(value: crate::WebPushSubscription) -> Self {
WebPushSubscription {
endpoint: value.endpoint,
p256dh: value.p256dh,
auth: value.auth,
}
}
}
impl From<WebPushSubscription> for crate::WebPushSubscription {
fn from(value: WebPushSubscription) -> Self {
crate::WebPushSubscription {
endpoint: value.endpoint,
p256dh: value.p256dh,
auth: value.auth,
}
}
}

View File

@@ -0,0 +1,42 @@
use reqwest::Client;
use revolt_config::config;
use revolt_result::Result;
use std::sync::LazyLock;
static CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
#[derive(Serialize, Deserialize)]
struct CaptchaResponse {
success: bool,
}
pub async fn check_captcha(token: Option<&str>) -> Result<()> {
let config = config().await;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
let Some(token) = token else {
return Err(create_error!(CaptchaFailed));
};
let response = CLIENT
.post("https://hcaptcha.com/siteverify")
.form(&[
("secret", config.api.security.captcha.hcaptcha_key.as_str()),
("response", token),
])
.send()
.await
.map_err(|_| create_error!(CaptchaFailed))?
.json::<CaptchaResponse>()
.await
.map_err(|_| create_error!(CaptchaFailed))?;
if response.success {
Ok(())
} else {
Err(create_error!(CaptchaFailed))
}
} else {
Ok(())
}
}

View File

@@ -0,0 +1,63 @@
#[cfg(feature = "mongodb")]
use ::mongodb::{ClientSession, SessionCursor};
use revolt_result::{Result, ToRevoltError};
use serde::Deserialize;
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum ChunkedDatabaseGenerator<T> {
#[cfg(feature = "mongodb")]
MongoDb {
session: ClientSession,
cursor: SessionCursor<T>,
},
Reference {
offset: usize,
data: Vec<T>,
},
}
impl<T: for<'d> Deserialize<'d> + Clone> ChunkedDatabaseGenerator<T> {
#[cfg(feature = "mongodb")]
pub fn new_mongo(session: ClientSession, cursor: SessionCursor<T>) -> Self {
Self::MongoDb { session, cursor }
}
pub fn new_reference(data: Vec<T>) -> Self {
Self::Reference { offset: 0, data }
}
pub async fn next(&mut self) -> Result<Option<T>> {
match self {
#[cfg(feature = "mongodb")]
Self::MongoDb { session, cursor } => {
cursor.next(session).await.transpose().to_internal_error()
}
Self::Reference { offset, data } => {
if let Some(value) = data.get(*offset) {
*offset += 1;
Ok(Some(value.clone()))
} else {
Ok(None)
}
}
}
}
pub async fn next_n(&mut self, n: usize) -> Result<Option<Vec<T>>> {
let mut docs = Vec::new();
while docs.len() < n {
if let Some(doc) = self.next().await? {
docs.push(doc);
} else if docs.is_empty() {
return Ok(None);
} else {
break;
}
}
Ok(Some(docs))
}
}

View File

@@ -0,0 +1,271 @@
use std::{collections::HashSet, sync::LazyLock};
use lettre::{
transport::smtp::{authentication::Credentials, client::Tls},
SmtpTransport,
};
use regex::Regex;
use revolt_config::{config, ApiSmtp};
use revolt_result::Result;
static SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new("([^@]+)(@.+)").unwrap());
static SYMBOL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\+.+|\\.").unwrap());
static HANDLEBARS: LazyLock<handlebars::Handlebars<'static>> =
LazyLock::new(handlebars::Handlebars::new);
static REVOLT_SOURCE_LIST: LazyLock<HashSet<String>> = LazyLock::new(|| {
include_str!("../../assets/revolt_source_list.txt")
.split('\n')
.map(|x| x.into())
.collect()
});
/// Strip special characters and aliases from emails
pub fn normalise_email(original: String) -> String {
let split = SPLIT.captures(&original).unwrap();
let mut clean = SYMBOL_RE
.replace_all(split.get(1).unwrap().as_str(), "")
.to_string();
clean.push_str(split.get(2).unwrap().as_str());
clean.to_lowercase()
}
/// Email template
#[derive(Clone)]
pub struct Template {
/// Title of the email
pub title: String,
/// Plain text version of this email
pub text: String,
/// HTML version of this email
pub html: Option<String>,
/// URL to redirect people to from the email
///
/// Use `{{url}}` to fill this field.
///
/// Any given URL will be suffixed with a unique token if applicable.
///
/// e.g. `https://example.com?t=` becomes `https://example.com?t=UNIQUE_CODE`
pub url: String,
}
/// Email templates
#[derive(Clone)]
pub struct Templates {
/// Template for email verification
pub verify: Template,
/// Template for password reset
pub reset: Template,
/// Template for password reset when the account already exists on creation
pub reset_existing: Template,
/// Template for account deletion
pub deletion: Template,
/// Template for suspention
pub suspension: Template,
}
pub async fn email_templates() -> Templates {
let config = config().await;
if std::env::var("TEST_DB").is_ok() {
Templates {
verify: Template {
title: "verify".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
reset: Template {
title: "reset".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
reset_existing: Template {
title: "reset_existing".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
deletion: Template {
title: "deletion".into(),
text: "[[{{url}}]]".into(),
url: "".into(),
html: None,
},
suspension: Template {
title: "suspension".into(),
text: "[[dummy]]".into(),
url: "".into(),
html: None,
},
}
} else if config.production {
Templates {
verify: Template {
title: "Verify your Stoat account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Stoat password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.html").into()),
},
reset_existing: Template {
title: "You already have a Stoat account, reset your password.".into(),
text: include_str!("../../templates/reset-existing.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset-existing.html").into()),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
suspension: Template {
title: "Account Suspension".to_string(),
html: Some(include_str!("../../templates/suspension.html").to_owned()),
text: include_str!("../../templates/suspension.txt").to_owned(),
url: Default::default(),
},
}
} else {
Templates {
verify: Template {
title: "Verify your account.".into(),
text: include_str!("../../templates/verify.whitelabel.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: None,
},
reset: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
reset_existing: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.whitelabel.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: None,
},
suspension: Template {
title: "Account Suspension".to_string(),
text: include_str!("../../templates/suspension.whitelabel.txt").to_owned(),
url: Default::default(),
html: None,
},
}
}
}
/// Create SMTP transport
pub fn create_transport(smtp: &ApiSmtp) -> SmtpTransport {
let relay = if smtp.use_starttls == Some(true) {
SmtpTransport::starttls_relay(&smtp.host).unwrap()
} else {
SmtpTransport::relay(&smtp.host).unwrap()
};
let relay = if let Some(port) = smtp.port {
relay.port(port.try_into().unwrap())
} else {
relay
};
let relay = if smtp.use_tls == Some(false) {
relay.tls(Tls::None)
} else {
relay
};
relay
.credentials(Credentials::new(
smtp.username.clone(),
smtp.password.clone(),
))
.build()
}
/// Render an email template
fn render_template(text: &str, variables: &handlebars::JsonValue) -> Result<String> {
HANDLEBARS
.render_template(text, variables)
.map_err(|_| create_error!(RenderFail))
}
/// Send an email
pub fn send_email(
smtp: &ApiSmtp,
address: String,
template: &Template,
variables: handlebars::JsonValue,
) -> Result<()> {
let m = lettre::Message::builder()
.from(smtp.from_address.parse().expect("valid `smtp_from`"))
.to(address.parse().expect("valid `smtp_to`"))
.subject(template.title.clone());
let m = if let Some(reply_to) = &smtp.reply_to {
m.reply_to(reply_to.parse().expect("valid `smtp_reply_to`"))
} else {
m
};
let text = render_template(&template.text, &variables).expect("valid `template`");
let m = if let Some(html) = &template.html {
m.multipart(lettre::message::MultiPart::alternative_plain_html(
text,
render_template(html, &variables).expect("valid `template`"),
))
} else {
m.body(text)
}
.expect("valid `message`");
use lettre::Transport;
let sender = create_transport(smtp);
match sender.send(&m) {
Ok(_) => Ok(()),
Err(error) => {
error!(
"Failed to send email to {}!\nlettre error: {}",
address, error
);
revolt_config::capture_error(&error);
Err(create_error!(EmailFailed))
}
}
}
pub fn validate_email(email: &str) -> Result<()> {
// Make sure this is an actual email
if !validator::validate_email(email) {
return Err(create_error!(IncorrectData {
with: "email".to_string()
}));
}
// Check if the email is blacklisted
if let Some(domain) = email.split('@').next_back() {
if REVOLT_SOURCE_LIST.contains(&domain.to_string()) {
return Err(create_error!(Blacklisted));
}
}
Ok(())
}

View File

@@ -0,0 +1,56 @@
#[cfg(feature = "rocket-impl")]
pub mod rocket {
use revolt_config::config;
use rocket::Request;
pub fn to_ip(request: &'_ Request<'_>) -> String {
request
.client_ip()
.map(|x| x.to_string())
.unwrap_or_default()
}
/// Find the actual IP of the client
pub async fn to_real_ip(request: &'_ Request<'_>) -> String {
if config().await.api.security.trust_cloudflare {
request
.headers()
.get_one("CF-Connecting-IP")
.map(|x| x.to_string())
.unwrap_or_else(|| to_ip(request))
} else {
to_ip(request)
}
}
}
#[cfg(feature = "axum-impl")]
pub mod axum {
use axum::{
extract::ConnectInfo,
http::request::Parts,
};
use revolt_config::config;
use std::net::SocketAddr;
pub fn to_ip(parts: &Parts) -> String {
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.ip().to_string())
.unwrap_or_default()
}
/// Find the actual IP of the client
pub async fn to_real_ip(parts: &Parts) -> String {
if config().await.api.security.trust_cloudflare {
parts
.headers
.get("CF-Connecting-IP")
.map(|x| x.to_str().unwrap().to_string())
.unwrap_or_else(|| to_ip(parts))
} else {
to_ip(parts)
}
}
}

View File

@@ -1,9 +1,17 @@
pub mod acker;
pub mod bridge;
pub mod bulk_permissions;
pub mod captcha;
pub mod chunked;
pub mod email;
mod funcs;
pub mod idempotency;
pub mod ip;
pub mod password;
pub mod permissions;
pub mod reference;
pub mod shield;
pub mod test_fixtures;
pub use funcs::*;
pub use chunked::ChunkedDatabaseGenerator;

View File

@@ -0,0 +1,72 @@
use reqwest::Client;
use sha1::Digest;
use std::{collections::HashSet, sync::LazyLock};
use revolt_config::config;
use revolt_result::{Result, ToRevoltError};
static CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
static ARGON_CONFIG: LazyLock<argon2::Config<'static>> = LazyLock::new(argon2::Config::default);
static TOP_100K_COMPROMISED: LazyLock<HashSet<String>> = LazyLock::new(|| {
include_str!("../../assets/pwned100k.txt")
.split('\n')
.map(|x| x.into())
.collect()
});
#[derive(Deserialize)]
struct EasyPwnedResult {
secure: bool,
}
/// Hash a password using argon2
pub fn hash_password(plaintext_password: String) -> Result<String> {
argon2::hash_encoded(
plaintext_password.as_bytes(),
nanoid::nanoid!(24).as_bytes(),
&ARGON_CONFIG,
)
.to_internal_error()
}
pub async fn assert_safe(password: &str) -> Result<()> {
// Make sure the password is long enough.
if password.len() < 8 {
return Err(create_error!(ShortPassword));
}
let config = config().await;
if !config.api.security.easypwned.is_empty() {
let mut hasher = sha1::Sha1::new();
hasher.update(password);
let pwd_hash = hasher.finalize();
let result = match CLIENT
.get(format!(
"{}/hash/{pwd_hash:#02x}",
&config.api.security.easypwned
))
.send()
.await
{
Ok(response) => match response.json::<EasyPwnedResult>().await {
Ok(result) => Ok(result.secure),
Err(e) => Err(e),
},
Err(e) => Err(e),
};
if let Err(e) = &result {
revolt_config::capture_error(e);
} else if result.is_ok_and(|b| b) {
return Ok(());
}
};
if TOP_100K_COMPROMISED.contains(password) {
return Err(create_error!(CompromisedPassword));
};
Ok(())
}

View File

@@ -0,0 +1,118 @@
use reqwest::Client;
use revolt_config::config;
use revolt_result::Result;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::LazyLock};
use crate::util::ip;
static CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct ShieldValidationInput {
/// Remote user IP
pub ip: Option<String>,
/// User provided email
pub email: Option<String>,
/// Request headers
pub headers: Option<HashMap<String, String>>,
/// Skip alerts and monitoring for this request
pub dry_run: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ValidationResult {
/// Whether this request was blocked
blocked: bool,
/// Reasons for the request being blocked
reasons: Vec<String>,
}
pub async fn validate_shield(input: ShieldValidationInput) -> Result<()> {
let shield = config().await.api.security.shield;
if !shield.host.is_empty() {
if let Ok(response) = CLIENT
.post(format!("{}/validate", &shield.host))
.json(&input)
.header("Authorization", &shield.key)
.send()
.await
{
let result = response
.json::<ValidationResult>()
.await
.map_err(|_| create_error!(InternalError))?;
if result.blocked {
return Err(create_error!(BlockedByShield));
}
}
}
Ok(())
}
#[cfg(feature = "rocket-impl")]
#[async_trait]
impl<'r> rocket::request::FromRequest<'r> for ShieldValidationInput {
type Error = revolt_result::Error;
#[allow(clippy::collapsible_match)]
async fn from_request(
request: &'r rocket::Request<'_>,
) -> rocket::request::Outcome<Self, Self::Error> {
rocket::request::Outcome::Success(ShieldValidationInput {
ip: Some(ip::rocket::to_real_ip(request).await),
headers: Some(
request
.headers()
.iter()
.map(|entry| (entry.name.to_string(), entry.value.to_string()))
.collect(),
),
..Default::default()
})
}
}
#[cfg(feature = "rocket-impl")]
impl<'r> revolt_rocket_okapi::request::OpenApiFromRequest<'r> for ShieldValidationInput {
fn from_request_input(
_gen: &mut revolt_rocket_okapi::r#gen::OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<revolt_rocket_okapi::request::RequestHeaderInput> {
Ok(revolt_rocket_okapi::request::RequestHeaderInput::None)
}
}
#[cfg(feature = "axum-impl")]
#[async_trait]
impl<S> axum::extract::FromRequestParts<S> for ShieldValidationInput {
type Rejection = axum::Json<revolt_result::Error> ;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(ShieldValidationInput {
ip: Some(ip::axum::to_real_ip(parts).await),
headers: Some(
parts
.headers
.iter()
.map(|(name, value)| {
(
name.to_string(),
value.to_str().map(|s| s.to_string()).unwrap_or_default(),
)
})
.collect(),
),
..Default::default()
})
}
}

View File

@@ -42,7 +42,7 @@
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img alt="Stoat Logo" class="w-24" src="https://stoat.chat/favicon.svg" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
<img alt="Stoat Logo" class="w-24" src="https://stoat.chat/favicon-stoat.svg" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td>
</tr>
</tbody>

View File

@@ -9,7 +9,7 @@
<body class="bg-light">
<div class="container">
<img alt="Stoat Logo" class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" />
<img alt="Stoat Logo" class="ax-center my-10 w-24" src="https://stoat.chat/favicon-stoat.svg" />
<div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Account Deletion</h1>
<p>

View File

@@ -42,7 +42,7 @@
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
<img class="w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td>
</tr>
</tbody>

View File

@@ -9,7 +9,7 @@
<div class="container">
<img
class="ax-center my-10 w-24"
src="https://stoat.chat/favicon.svg"
src="https://stoat.chat/favicon-stoat.svg"
alt="Stoat Logo"
/>
<div class="card p-6 p-lg-10 space-y-4">

View File

@@ -42,7 +42,7 @@
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
<img class="w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td>
</tr>
</tbody>

View File

@@ -9,7 +9,7 @@
<body class="bg-light">
<div class="container">
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" />
<div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Password Reset</h1>
<p>You requested a password reset, click below to continue.</p>

View File

@@ -42,7 +42,7 @@
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
<img class="w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td>
</tr>
</tbody>

View File

@@ -9,7 +9,7 @@
<body class="bg-light">
<div class="container">
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" />
<div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Account Suspended</h1>
<p>Your account has been suspended, for one or more reasons:</p>

View File

@@ -42,7 +42,7 @@
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
<img class="w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td>
</tr>
</tbody>

View File

@@ -9,7 +9,7 @@
<body class="bg-light">
<div class="container">
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
<img class="ax-center my-10 w-24" src="https://stoat.chat/favicon-stoat.svg" alt="Stoat Logo" />
<div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Almost there!</h1>
<p>To complete your sign up, we just need to verify your email.</p>

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-files"
version = "0.12.1"
version = "0.13.7"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-models"
version = "0.12.1"
version = "0.13.7"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -41,6 +41,7 @@ iso8601-timestamp = { workspace = true, features = ["schema", "bson"] }
# Spec Generation
schemars = { workspace = true, features = ["indexmap2"], optional = true }
utoipa = { workspace = true, optional = true }
serde_json = { workspace = true }
# Validation
validator = { workspace = true, features = ["derive"], optional = true }

View File

@@ -0,0 +1,70 @@
auto_derived!(
/// # Change Email Data
pub struct DataChangeEmail {
/// Valid email address
pub email: String,
/// Current password
pub current_password: String,
}
/// # Change Data
pub struct DataChangePassword {
/// New password
pub password: String,
/// Current password
pub current_password: String,
}
/// # Account Deletion Token
pub struct DataAccountDeletion {
/// Deletion token
pub token: String,
}
/// # Account Data
pub struct DataCreateAccount {
/// Valid email address
pub email: String,
/// Password
pub password: String,
/// Invite code
pub invite: Option<String>,
/// Captcha verification code
pub captcha: Option<String>,
}
pub struct AccountInfo {
#[serde(rename = "_id")]
pub id: String,
pub email: String,
}
/// # Password Reset
pub struct DataPasswordReset {
/// Reset token
pub token: String,
/// New password
pub password: String,
/// Whether to logout all sessions
#[serde(default)]
pub remove_sessions: bool,
}
/// # Resend Information
pub struct DataResendVerification {
/// Email associated with the account
pub email: String,
/// Captcha verification code
pub captcha: Option<String>,
}
/// # Reset Information
pub struct DataSendPasswordReset {
/// Email associated with the account
pub email: String,
/// Captcha verification code
pub captcha: Option<String>,
}
);

View File

@@ -314,6 +314,12 @@ auto_derived!(
/// Only used when the user is the first one connected.
pub recipients: Option<Vec<String>>,
}
pub struct ChannelSlowmode {
pub channel_id: String,
pub duration: u64,
pub retry_after: u64,
}
);
impl Channel {

View File

@@ -54,4 +54,22 @@ auto_derived!(
#[serde(default)]
pub nsfw: bool,
}
/// Partial emoji representation
#[derive(Default)]
pub struct PartialEmoji {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub name: Option<String>,
}
/// Edit emoji information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditEmoji {
/// Emoji name
#[cfg_attr(
feature = "validator",
validate(length(min = 1, max = 32), regex = "RE_EMOJI")
)]
pub name: Option<String>,
}
);

View File

@@ -261,7 +261,7 @@ auto_derived!(
pub nonce: Option<String>,
/// Message content to send
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
#[cfg_attr(feature = "validator", validate(length(min = 0)))]
pub content: Option<String>,
/// Attachments to include in message
pub attachments: Option<Vec<String>>,
@@ -345,7 +345,7 @@ auto_derived!(
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditMessage {
/// New message content
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub content: Option<String>,
/// Embeds to include in the message
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]

View File

@@ -0,0 +1,68 @@
auto_derived!(
pub struct MFATicket {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Account Id
pub account_id: String,
/// Unique Token
pub token: String,
/// Whether this ticket has been validated
/// (can be used for account actions)
pub validated: bool,
/// Whether this ticket is authorised
/// (can be used to log a user in)
pub authorised: bool,
/// TOTP code at time of ticket creation
pub last_totp_code: Option<String>,
}
#[serde(untagged)]
pub enum ResponseVerify {
NoTicket,
WithTicket {
/// Authorised MFA ticket, can be used to log in
ticket: MFATicket,
},
}
/// MFA response
#[serde(untagged)]
pub enum MFAResponse {
Password { password: String },
Recovery { recovery_code: String },
Totp { totp_code: String },
}
#[derive(Default)]
pub struct MultiFactorStatus {
#[serde(skip_serializing_if = "crate::if_false", default)]
pub email_otp: bool,
#[serde(skip_serializing_if = "crate::if_false", default)]
pub trusted_handover: bool,
#[serde(skip_serializing_if = "crate::if_false", default)]
pub email_mfa: bool,
#[serde(skip_serializing_if = "crate::if_false", default)]
pub totp_mfa: bool,
#[serde(skip_serializing_if = "crate::if_false", default)]
pub security_key_mfa: bool,
#[serde(skip_serializing_if = "crate::if_false", default)]
pub recovery_active: bool,
}
pub enum MFAMethod {
Password,
Recovery,
Totp,
}
/// # Totp Secret
pub struct ResponseTotpSecret {
pub secret: String,
}
);

Some files were not shown because too many files have changed in this diff Show More