chore(monorepo): delta, january, quark

This commit is contained in:
Paul Makles
2022-06-02 00:04:22 +01:00
parent 5d8432e267
commit 2ce610e1e7
232 changed files with 18094 additions and 554 deletions

149
crates/delta/src/main.rs Normal file
View File

@@ -0,0 +1,149 @@
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate rocket_okapi;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate lazy_static;
extern crate ctrlc;
pub mod routes;
pub mod util;
pub mod version;
use log::info;
use rauth::{
config::{Captcha, Config, EmailVerification, SMTPSettings, Template, Templates},
logic::Auth,
};
use revolt_quark::variables::delta::{
APP_URL, HCAPTCHA_KEY, INVITE_ONLY, SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_USERNAME,
USE_EMAIL, USE_HCAPTCHA,
};
use revolt_quark::DatabaseInfo;
use rocket_cors::AllowedOrigins;
use std::str::FromStr;
#[async_std::main]
async fn main() {
let _guard = revolt_quark::setup_logging();
info!(
"Starting Revolt server [version {}].",
crate::version::VERSION
);
revolt_quark::variables::delta::preflight_checks();
#[cfg(debug_assertions)]
ctrlc::set_handler(move || {
// Force ungraceful exit to avoid hang.
std::process::exit(0);
})
.expect("Error setting Ctrl-C handler");
let cors = rocket_cors::CorsOptions {
allowed_origins: AllowedOrigins::All,
allowed_methods: [
"Get", "Put", "Post", "Delete", "Options", "Head", "Trace", "Connect", "Patch",
]
.iter()
.map(|s| FromStr::from_str(s).unwrap())
.collect(),
..Default::default()
}
.to_cors()
.expect("Failed to create CORS.");
let mut config = Config {
email_verification: if *USE_EMAIL {
EmailVerification::Enabled {
smtp: SMTPSettings {
from: (*SMTP_FROM).to_string(),
host: (*SMTP_HOST).to_string(),
username: (*SMTP_USERNAME).to_string(),
password: (*SMTP_PASSWORD).to_string(),
reply_to: Some("support@revolt.chat".into()),
port: None,
use_tls: None,
},
expiry: Default::default(),
templates: Templates {
verify: Template {
title: "Verify your Revolt account.".into(),
text: include_str!(crate::asset!("templates/verify.txt")).into(),
url: format!("{}/login/verify/", *APP_URL),
html: None,
},
reset: Template {
title: "Reset your Revolt password.".into(),
text: include_str!(crate::asset!("templates/reset.txt")).into(),
url: format!("{}/login/reset/", *APP_URL),
html: None,
},
welcome: None,
},
}
} else {
EmailVerification::Disabled
},
..Default::default()
};
if *INVITE_ONLY {
config.invite_only = true;
}
if *USE_HCAPTCHA {
config.captcha = Captcha::HCaptcha {
secret: HCAPTCHA_KEY.clone(),
};
}
let db = DatabaseInfo::Auto.connect().await.unwrap();
db.migrate_database().await.unwrap();
// This is entirely temporary code until rauth is migrated to quark.
// (and / or otherwise gets updated to MongoDB v2 driver)
let mongo_db = mongodb::Client::with_uri_str(
&std::env::var("MONGODB").unwrap_or_else(|_| "mongodb://localhost".to_string()),
)
.await
.expect("Failed to init db connection.");
rauth::entities::sync_models(&mongo_db.database("revolt")).await;
// Launch background task workers.
async_std::task::spawn(revolt_quark::tasks::start_workers(db.clone()));
let auth = Auth::new(mongo_db.database("revolt"), config);
let rocket = rocket::build();
routes::mount(rocket)
.mount("/", rocket_cors::catch_all_options_routes())
.mount("/", util::ratelimiter::routes())
.mount(
"/swagger/",
rocket_okapi::swagger_ui::make_swagger_ui(&rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "../openapi.json".to_owned(),
..Default::default()
}),
)
.manage(auth)
.manage(db)
.manage(cors.clone())
.attach(util::ratelimiter::RatelimitFairing)
.attach(cors)
.launch()
.await
.unwrap();
}
/// Resolve asset
macro_rules! asset {
($path:literal) => {
concat!(env!("CARGO_MANIFEST_DIR"), "/assets/", $path)
};
}
pub(crate) use asset;

View File

@@ -0,0 +1,65 @@
use crate::util::regex::RE_USERNAME;
use nanoid::nanoid;
use revolt_quark::{
models::{user::BotInformation, Bot, User},
variables::delta::MAX_BOT_COUNT,
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::Deserialize;
use ulid::Ulid;
use validator::Validate;
/// # Bot Details
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataCreateBot {
/// Bot username
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
name: String,
}
/// # Create Bot
///
/// Create a new Revolt bot.
#[openapi(tag = "Bots")]
#[post("/create", data = "<info>")]
pub async fn create_bot(db: &Db, user: User, info: Json<DataCreateBot>) -> Result<Json<Bot>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
if db.get_number_of_bots_by_user(&user.id).await? >= *MAX_BOT_COUNT {
return Err(Error::ReachedMaximumBots);
}
if db.is_username_taken(&info.name).await? {
return Err(Error::UsernameTaken);
}
let id = Ulid::new().to_string();
let bot_user = User {
id: id.clone(),
username: info.name.trim().to_string(),
bot: Some(BotInformation {
owner: user.id.clone(),
}),
..Default::default()
};
let bot = Bot {
id,
owner: user.id,
token: nanoid!(64),
..Default::default()
};
db.insert_user(&bot_user).await?;
db.insert_bot(&bot).await?;
Ok(Json(bot))
}

View File

@@ -0,0 +1,19 @@
use revolt_quark::{models::User, Db, EmptyResponse, Error, Ref, Result};
/// # Delete Bot
///
/// Delete a bot by its id.
#[openapi(tag = "Bots")]
#[delete("/<target>")]
pub async fn delete_bot(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
bot.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,107 @@
use crate::util::regex::RE_USERNAME;
use revolt_quark::{
models::{
bot::{FieldsBot, PartialBot},
Bot, User,
},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Bot Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditBot {
/// Bot username
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Whether the bot can be added by anyone
public: Option<bool>,
/// Whether analytics should be gathered for this bot
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
analytics: Option<bool>,
/// Interactions URL
#[validate(length(min = 1, max = 2048))]
interactions_url: Option<String>,
/// Fields to remove from bot object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsBot>>,
}
/// # Edit Bot
///
/// Edit bot details by its id.
#[openapi(tag = "Bots")]
#[patch("/<target>", data = "<data>")]
pub async fn edit_bot(
db: &Db,
user: User,
target: Ref,
data: Json<DataEditBot>,
) -> Result<Json<Bot>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
if let Some(name) = data.name {
if db.is_username_taken(&name).await? {
return Err(Error::UsernameTaken);
}
let mut user = db.fetch_user(&bot.id).await?;
user.update_username(db, name).await?;
}
if data.public.is_none()
&& data.analytics.is_none()
&& data.interactions_url.is_none()
&& data.remove.is_none()
{
return Ok(Json(bot));
}
let DataEditBot {
public,
analytics,
interactions_url,
remove,
..
} = data;
let mut partial = PartialBot {
public,
analytics,
interactions_url,
..Default::default()
};
if let Some(remove) = &remove {
for field in remove {
bot.remove(field);
}
if remove.iter().any(|x| x == &FieldsBot::Token) {
partial.token = Some(bot.token.clone());
}
}
db.update_bot(&bot.id, &partial, remove.unwrap_or_default())
.await?;
bot.apply_options(partial);
Ok(Json(bot))
}

View File

@@ -0,0 +1,36 @@
use revolt_quark::{
models::{Bot, User},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Bot Response
#[derive(Serialize, JsonSchema)]
pub struct BotResponse {
/// Bot object
bot: Bot,
/// User object
user: User,
}
/// # Fetch Bot
///
/// Fetch details of a bot you own by its id.
#[openapi(tag = "Bots")]
#[get("/<target>")]
pub async fn fetch_bot(db: &Db, user: User, target: Ref) -> Result<Json<BotResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
Ok(Json(BotResponse {
user: db.fetch_user(&bot.id).await?.foreign(),
bot,
}))
}

View File

@@ -0,0 +1,42 @@
use revolt_quark::{
models::{Bot, User},
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Owned Bots Response
///
/// Both lists are sorted by their IDs.
#[derive(Serialize, JsonSchema)]
pub struct OwnedBotsResponse {
/// Bot objects
bots: Vec<Bot>,
/// User objects
users: Vec<User>,
}
/// # Fetch Owned Bots
///
/// Fetch all of the bots that you have control over.
#[openapi(tag = "Bots")]
#[get("/@me")]
pub async fn fetch_owned_bots(db: &Db, user: User) -> Result<Json<OwnedBotsResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let mut bots = db.fetch_bots_by_user(&user.id).await?;
let user_ids = bots
.iter()
.map(|x| x.id.to_owned())
.collect::<Vec<String>>();
let mut users = db.fetch_users(&user_ids).await?;
// Ensure the lists match up exactly.
bots.sort_by(|a, b| a.id.cmp(&b.id));
users.sort_by(|a, b| a.id.cmp(&b.id));
Ok(Json(OwnedBotsResponse { users, bots }))
}

View File

@@ -0,0 +1,44 @@
use revolt_quark::{
models::{File, User},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Public Bot
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct PublicBot {
/// Bot Id
#[serde(rename = "_id")]
id: String,
/// Bot Username
username: String,
/// Profile Avatar
#[serde(skip_serializing_if = "Option::is_none")]
avatar: Option<File>,
/// Profile Description
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
/// # Fetch Public Bot
///
/// Fetch details of a public (or owned) bot by its id.
#[openapi(tag = "Bots")]
#[get("/<target>/invite")]
pub async fn fetch_public_bot(db: &Db, user: Option<User>, target: Ref) -> Result<Json<PublicBot>> {
let bot = target.as_bot(db).await?;
if !bot.public && user.map_or(true, |x| x.id != bot.owner) {
return Err(Error::NotFound);
}
let user = db.fetch_user(&bot.id).await?;
Ok(Json(PublicBot {
id: bot.id,
username: user.username,
avatar: user.avatar,
description: user.profile.and_then(|p| p.content),
}))
}

View File

@@ -0,0 +1,71 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
use rocket::serde::json::Json;
use serde::Deserialize;
/// # Invite Destination
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum InviteBotDestination {
/// Invite to a server
Server {
/// Server Id
server: String,
},
/// Invite to a group
Group {
/// Group Id
group: String,
},
}
/// # Invite Bot
///
/// Invite a bot to a server or group by its id.`
#[openapi(tag = "Bots")]
#[post("/<target>/invite", data = "<dest>")]
pub async fn invite_bot(
db: &Db,
user: User,
target: Ref,
dest: Json<InviteBotDestination>,
) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if !bot.public && bot.owner != user.id {
return Err(Error::BotIsPrivate);
}
match dest.into_inner() {
InviteBotDestination::Server { server } => {
let server = db.fetch_server(&server).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageServer)
.await?;
let user = db.fetch_user(&bot.id).await?;
server
.create_member(db, user, None)
.await
.map(|_| EmptyResponse)
}
InviteBotDestination::Group { group } => {
let mut channel = db.fetch_channel(&group).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
channel
.add_user_to_group(db, &bot.id, &user.id)
.await
.map(|_| EmptyResponse)
}
}
}

View File

@@ -0,0 +1,22 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod create;
mod delete;
mod edit;
mod fetch;
mod fetch_owned;
mod fetch_public;
mod invite;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
create::create_bot,
invite::invite_bot,
fetch_public::fetch_public_bot,
fetch::fetch_bot,
fetch_owned::fetch_owned_bots,
edit::edit_bot,
delete::delete_bot,
]
}

View File

@@ -0,0 +1,23 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Acknowledge Message
///
/// Lets the server and all other clients know that we've seen this message id in this channel.
#[openapi(tag = "Messaging")]
#[put("/<target>/ack/<message>")]
pub async fn req(db: &Db, user: User, target: Ref, message: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
channel
.ack(&user.id, &message.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,41 @@
use revolt_quark::{
models::{channel::PartialChannel, Channel, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Close Channel
///
/// Deletes a server channel, leaves a group or closes a group.
#[openapi(tag = "Channel Information")]
#[delete("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
let mut channel = target.as_channel(db).await?;
let mut perms = perms(&user).channel(&channel);
perms.throw_permission(db, Permission::ViewChannel).await?;
match &channel {
Channel::SavedMessages { .. } => Err(Error::NoEffect),
Channel::DirectMessage { .. } => channel
.update(
db,
PartialChannel {
active: Some(false),
..Default::default()
},
vec![],
)
.await
.map(|_| EmptyResponse),
Channel::Group { .. } => channel
.remove_user_from_group(db, &user.id, None)
.await
.map(|_| EmptyResponse),
Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => {
perms
.throw_permission(db, Permission::ManageChannel)
.await?;
channel.delete(db).await.map(|_| EmptyResponse)
}
}
}

View File

@@ -0,0 +1,173 @@
use revolt_quark::{
models::{
channel::{Channel, FieldsChannel, PartialChannel},
message::SystemMessage,
File, User,
},
perms, Database, Error, Permission, Ref, Result,
};
use rocket::{serde::json::Json, State};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Channel Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditChannel {
/// Channel name
#[validate(length(min = 1, max = 32))]
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Channel description
#[validate(length(min = 0, max = 1024))]
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Icon
///
/// Provide an Autumn attachment Id.
#[validate(length(min = 1, max = 128))]
icon: Option<String>,
/// Whether this channel is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
#[validate(length(min = 1))]
remove: Option<Vec<FieldsChannel>>,
}
/// # Edit Channel
///
/// Edit a channel object by its id.
#[openapi(tag = "Channel Information")]
#[patch("/<target>", data = "<data>")]
pub async fn req(
db: &State<Database>,
user: User,
target: Ref,
data: Json<DataEditChannel>,
) -> Result<Json<Channel>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ManageChannel)
.await?;
if data.name.is_none()
&& data.description.is_none()
&& data.icon.is_none()
&& data.nsfw.is_none()
&& data.remove.is_none()
{
return Ok(Json(channel));
}
let mut partial: PartialChannel = Default::default();
match &mut channel {
Channel::Group {
id,
name,
description,
icon,
nsfw,
..
}
| Channel::TextChannel {
id,
name,
description,
icon,
nsfw,
..
}
| Channel::VoiceChannel {
id,
name,
description,
icon,
nsfw,
..
} => {
if let Some(fields) = &data.remove {
if fields.contains(&FieldsChannel::Icon) {
if let Some(icon) = &icon {
db.mark_attachment_as_deleted(&icon.id).await?;
}
}
for field in fields {
match field {
FieldsChannel::Description => {
description.take();
}
FieldsChannel::Icon => {
icon.take();
}
_ => {}
}
}
}
if let Some(icon_id) = data.icon {
partial.icon = Some(File::use_icon(db, &icon_id, id).await?);
*icon = partial.icon.clone();
}
if let Some(new_name) = data.name {
*name = new_name.clone();
partial.name = Some(new_name);
}
if let Some(new_description) = data.description {
partial.description = Some(new_description);
*description = partial.description.clone();
}
if let Some(new_nsfw) = data.nsfw {
*nsfw = new_nsfw;
partial.nsfw = Some(new_nsfw);
}
// Send out mutation system messages.
if let Channel::Group { .. } = &channel {
if let Some(name) = &partial.name {
SystemMessage::ChannelRenamed {
name: name.to_string(),
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
if partial.description.is_some() {
SystemMessage::ChannelDescriptionChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
if partial.icon.is_some() {
SystemMessage::ChannelIconChanged { by: user.id }
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
}
channel
.update(db, partial, data.remove.unwrap_or_default())
.await?;
}
_ => return Err(Error::InvalidOperation),
};
Ok(Json(channel))
}

View File

@@ -0,0 +1,21 @@
use revolt_quark::{
models::{Channel, User},
perms, Database, Permission, Ref, Result,
};
use rocket::{serde::json::Json, State};
/// # Fetch Channel
///
/// Fetch channel by its id.
#[openapi(tag = "Channel Information")]
#[get("/<target>")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<Channel>> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
Ok(Json(channel))
}

View File

@@ -0,0 +1,32 @@
use revolt_quark::{
models::{Channel, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Add Member to Group
///
/// Adds another user to the group.
#[openapi(tag = "Groups")]
#[put("/<target>/recipients/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let mut channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
match &channel {
Channel::Group { .. } => {
let member = member.as_user(db).await?;
channel
.add_user_to_group(db, &member.id, &user.id)
.await
.map(|_| EmptyResponse)
}
_ => Err(Error::InvalidOperation),
}
}

View File

@@ -0,0 +1,84 @@
use std::{collections::HashSet, iter::FromIterator};
use revolt_quark::{
get_relationship,
models::{user::RelationshipStatus, Channel, User},
variables::delta::MAX_GROUP_SIZE,
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
/// # Group Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataCreateGroup {
/// Group name
#[validate(length(min = 1, max = 32))]
name: String,
/// Group description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Array of user IDs to add to the group
///
/// Must be friends with these users.
#[validate(length(min = 0, max = 49))]
users: Vec<String>,
/// Whether this group is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
}
/// # Create Group
///
/// Create a new group channel.
#[openapi(tag = "Groups")]
#[post("/create", data = "<info>")]
pub async fn req(db: &Db, user: User, info: Json<DataCreateGroup>) -> Result<Json<Channel>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut set: HashSet<String> = HashSet::from_iter(info.users.into_iter());
set.insert(user.id.clone());
if set.len() > *MAX_GROUP_SIZE {
return Err(Error::GroupTooLarge {
max: *MAX_GROUP_SIZE,
});
}
for target in &set {
match get_relationship(&user, target) {
RelationshipStatus::Friend | RelationshipStatus::User => {}
_ => {
return Err(Error::NotFriends);
}
}
}
let group = Channel::Group {
id: Ulid::new().to_string(),
name: info.name,
owner: user.id,
description: info.description,
recipients: set.into_iter().collect::<Vec<String>>(),
icon: None,
last_message_id: None,
permissions: None,
nsfw: info.nsfw.unwrap_or(false),
};
group.create(db).await?;
Ok(Json(group))
}

View File

@@ -0,0 +1,42 @@
use revolt_quark::{
models::{Channel, User},
Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Remove Member from Group
///
/// Removes a user from the group.
#[openapi(tag = "Groups")]
#[delete("/<target>/recipients/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
match &channel {
Channel::Group {
owner, recipients, ..
} => {
if &user.id != owner {
return Error::from_permission(Permission::ManageChannel);
}
let member = member.as_user(db).await?;
if user.id == member.id {
return Err(Error::CannotRemoveYourself);
}
if !recipients.iter().any(|x| *x == member.id) {
return Err(Error::NotInGroup);
}
channel
.remove_user_from_group(db, &member.id, Some(&user.id))
.await
.map(|_| EmptyResponse)
}
_ => Err(Error::InvalidOperation),
}
}

View File

@@ -0,0 +1,27 @@
use revolt_quark::{
models::{Invite, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Create Invite
///
/// Creates an invite to this channel.
///
/// Channel must be a `TextChannel`.
#[openapi(tag = "Channel Invites")]
#[post("/<target>/invites")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Invite>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
Invite::create(db, &user, &channel).await.map(Json)
}

View File

@@ -0,0 +1,35 @@
use revolt_quark::{
models::{Channel, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Group Members
///
/// Retrieves all users who are part of this group.
#[openapi(tag = "Groups")]
#[get("/<target>/members")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Vec<User>>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
if let Channel::Group { recipients, .. } = channel {
Ok(Json(
db.fetch_users(&recipients)
.await?
.into_iter()
.map(|x| x.with_relationship(&user))
.collect::<Vec<User>>(),
))
} else {
Err(Error::InvalidOperation)
}
}

View File

@@ -0,0 +1,59 @@
use chrono::Utc;
use revolt_quark::{
models::{Message, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Deserialize;
use validator::Validate;
/// # Search Parameters
#[derive(Validate, Deserialize, JsonSchema)]
pub struct OptionsBulkDelete {
/// Message IDs
#[validate(length(min = 1, max = 100))]
ids: Vec<String>,
}
/// # Bulk Delete Messages
///
/// Delete multiple messages you've sent or one you have permission to delete.
///
/// This will always require `ManageMessages` permission regardless of whether you own the message or not.
///
/// Messages must have been sent within the past 1 week.
#[openapi(tag = "Messaging")]
#[delete("/<target>/messages/bulk", data = "<options>", rank = 1)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: Json<OptionsBulkDelete>,
) -> Result<EmptyResponse> {
let options = options.into_inner();
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
for id in &options.ids {
if ulid::Ulid::from_string(id)
.map_err(|_| Error::InvalidOperation)?
.datetime()
.signed_duration_since(Utc::now())
.num_days()
.abs()
> 7
{
return Err(Error::InvalidOperation);
}
}
perms(&user)
.channel(&target.as_channel(db).await?)
.throw_permission(db, Permission::ManageMessages)
.await?;
Message::bulk_delete(db, &target.id, options.ids)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,22 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Delete Message
///
/// Delete a message you've sent or one you have permission to delete.
#[openapi(tag = "Messaging")]
#[delete("/<target>/messages/<msg>", rank = 2)]
pub async fn req(db: &Db, user: User, target: Ref, msg: Ref) -> Result<EmptyResponse> {
let message = msg.as_message(db).await?;
if message.channel != target.id {
return Err(Error::NotFound);
}
if message.author != user.id {
perms(&user)
.channel(&target.as_channel(db).await?)
.throw_permission(db, Permission::ManageMessages)
.await?;
}
message.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,93 @@
use revolt_quark::{
models::message::{PartialMessage, SendableEmbed},
models::{Message, User},
types::january::Embed,
Db, Error, Ref, Result, Timestamp,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Message Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditMessage {
/// New message content
#[validate(length(min = 1, max = 2000))]
content: Option<String>,
/// Embeds to include in the message
#[validate(length(min = 0, max = 10))]
embeds: Option<Vec<SendableEmbed>>,
}
/// # Edit Message
///
/// Edits a message that you've previously sent.
#[openapi(tag = "Messaging")]
#[patch("/<target>/messages/<msg>", data = "<edit>")]
pub async fn req(
db: &Db,
user: User,
target: String,
msg: Ref,
edit: Json<DataEditMessage>,
) -> Result<Json<Message>> {
let edit = edit.into_inner();
edit.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut message = msg.as_message(db).await?;
if message.channel != target {
return Err(Error::NotFound);
}
if message.author != user.id {
return Err(Error::CannotEditMessage);
}
message.edited = Some(Timestamp::now_utc());
let mut partial = PartialMessage {
edited: message.edited,
..Default::default()
};
// 1. Handle content update
if let Some(content) = &edit.content {
partial.content = Some(content.clone());
}
// 2. Clear any auto generated embeds
let mut new_embeds: Vec<Embed> = vec![];
if let Some(embeds) = &message.embeds {
for embed in embeds {
if let Embed::Text(embed) = embed {
new_embeds.push(Embed::Text(embed.clone()))
}
}
}
// 3. Replace if we are given new embeds
if let Some(embeds) = edit.embeds {
new_embeds.clear();
for embed in embeds {
new_embeds.push(embed.clone().into_embed(db, message.id.clone()).await?);
}
}
partial.embeds = Some(new_embeds);
message.update(db, partial).await?;
// Queue up a task for processing embeds
if let Some(content) = edit.content {
revolt_quark::tasks::process_embeds::queue(
message.channel.to_string(),
message.id.to_string(),
content,
)
.await;
}
Ok(Json(message))
}

View File

@@ -0,0 +1,26 @@
use revolt_quark::{
models::{Message, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Message
///
/// Retrieves a message by its id.
#[openapi(tag = "Messaging")]
#[get("/<target>/messages/<msg>")]
pub async fn req(db: &Db, user: User, target: Ref, msg: Ref) -> Result<Json<Message>> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
let message = msg.as_message(db).await?;
if message.channel != channel.as_id() {
return Err(Error::NotFound);
}
Ok(Json(message))
}

View File

@@ -0,0 +1,81 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageSort},
User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Query Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema, FromForm)]
pub struct OptionsQueryMessages {
/// Maximum number of messages to fetch
///
/// For fetching nearby messages, this is \`(limit + 1)\`.
#[validate(range(min = 1, max = 100))]
limit: Option<i64>,
/// Message id before which messages should be fetched
#[validate(length(min = 26, max = 26))]
before: Option<String>,
/// Message id after which messages should be fetched
#[validate(length(min = 26, max = 26))]
after: Option<String>,
/// Message sort direction
sort: Option<MessageSort>,
/// Message id to search around
///
/// Specifying 'nearby' ignores 'before', 'after' and 'sort'.
/// It will also take half of limit rounded as the limits to each side.
/// It also fetches the message ID specified.
#[validate(length(min = 26, max = 26))]
nearby: Option<String>,
/// Whether to include user (and member, if server channel) objects
include_users: Option<bool>,
}
/// # Fetch Messages
///
/// Fetch multiple messages.
#[openapi(tag = "Messaging")]
#[get("/<target>/messages?<options..>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: OptionsQueryMessages,
) -> Result<Json<BulkMessageResponse>> {
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
if let Some(MessageSort::Relevance) = options.sort {
return Err(Error::InvalidOperation);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ReadMessageHistory)
.await?;
let OptionsQueryMessages {
limit,
before,
after,
sort,
nearby,
include_users,
..
} = options;
let messages = db
.fetch_messages(channel.id(), limit, before, after, sort, nearby)
.await?;
BulkMessageResponse::transform(db, &channel, messages, include_users)
.await
.map(Json)
}

View File

@@ -0,0 +1,26 @@
use revolt_quark::{models::User, Ref, Result};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Query Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct OptionsQueryStale {
/// Array of message IDs
#[validate(length(min = 0, max = 150))]
ids: Vec<String>,
}
/// # Poll Message Changes
///
/// This route returns any changed message objects and tells you if any have been deleted.
///
/// Don't actually poll this route, instead use this to update your local database.
///
/// **DEPRECATED**
#[openapi(tag = "Messaging")]
#[post("/<_target>/messages/stale", data = "<_data>")]
pub async fn req(_user: User, _target: Ref, _data: Json<OptionsQueryStale>) -> Result<()> {
Ok(())
}

View File

@@ -0,0 +1,82 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageSort},
User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Search Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct OptionsMessageSearch {
/// Full-text search query
///
/// See [MongoDB documentation](https://docs.mongodb.com/manual/text-search/#-text-operator) for more information.
#[validate(length(min = 1, max = 64))]
query: String,
/// Maximum number of messages to fetch
#[validate(range(min = 1, max = 100))]
limit: Option<i64>,
/// Message id before which messages should be fetched
#[validate(length(min = 26, max = 26))]
before: Option<String>,
/// Message id after which messages should be fetched
#[validate(length(min = 26, max = 26))]
after: Option<String>,
/// Message sort direction
///
/// By default, it will be sorted by relevance.
#[serde(default = "MessageSort::default")]
sort: MessageSort,
/// Whether to include user (and member, if server channel) objects
include_users: Option<bool>,
}
/// # Search for Messages
///
/// This route searches for messages within the given parameters.
#[openapi(tag = "Messaging")]
#[post("/<target>/search", data = "<options>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: Json<OptionsMessageSearch>,
) -> Result<Json<BulkMessageResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let options = options.into_inner();
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ReadMessageHistory)
.await?;
let OptionsMessageSearch {
query,
limit,
before,
after,
sort,
include_users,
} = options;
let messages = db
.search_messages(channel.id(), &query, limit, before, after, sort)
.await?;
BulkMessageResponse::transform(db, &channel, messages, include_users)
.await
.map(Json)
}

View File

@@ -0,0 +1,194 @@
use std::collections::HashSet;
use revolt_quark::{
models::{
message::{Masquerade, Reply, SendableEmbed},
Message, User,
},
perms, Db, Error, Permission, Ref, Result,
};
use regex::Regex;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
use crate::util::idempotency::IdempotencyKey;
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataMessageSend {
/// Unique token to prevent duplicate message sending
///
/// **This is deprecated and replaced by `Idempotency-Key`!**
nonce: Option<String>,
/// Message content to send
#[validate(length(min = 0, max = 2000))]
content: Option<String>,
/// Attachments to include in message
#[validate(length(min = 1, max = 128))]
attachments: Option<Vec<String>>,
/// Messages to reply to
replies: Option<Vec<Reply>>,
/// Embeds to include in message
#[validate(length(min = 1, max = 10))]
embeds: Option<Vec<SendableEmbed>>,
/// Masquerade to apply to this message
#[validate]
masquerade: Option<Masquerade>,
}
lazy_static! {
// ignoring I L O and U is intentional
static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap();
}
/// # Send Message
///
/// Sends a message to the given channel.
#[openapi(tag = "Messaging")]
#[post("/<target>/messages", data = "<data>")]
pub async fn message_send(
db: &Db,
user: User,
target: Ref,
data: Json<DataMessageSend>,
mut idempotency: IdempotencyKey,
) -> Result<Json<Message>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
idempotency.consume_nonce(data.nonce).await?;
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::SendMessage)
.await?;
if (data.content.as_ref().map_or(true, |v| v.is_empty()))
&& (data.attachments.as_ref().map_or(true, |v| v.is_empty()))
&& (data.embeds.as_ref().map_or(true, |v| v.is_empty()))
{
return Err(Error::EmptyMessage);
}
let message_id = Ulid::new().to_string();
let mut message = Message {
id: message_id.clone(),
channel: channel.id().to_string(),
author: user.id.clone(),
masquerade: data.masquerade,
..Default::default()
};
// 1. Parse mentions in message.
let mut mentions = HashSet::new();
if let Some(content) = &data.content {
for capture in RE_MENTION.captures_iter(content) {
if let Some(mention) = capture.get(1) {
mentions.insert(mention.as_str().to_string());
}
}
}
// 2. Verify permissions for masquerade.
if message.masquerade.is_some() {
permissions
.throw_permission(db, Permission::Masquerade)
.await?;
}
// 3. Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > 5 {
return Err(Error::TooManyReplies);
}
for Reply { id, mention } in entries {
let message = Ref::from_unchecked(id).as_message(db).await?;
replies.insert(message.id);
if mention {
mentions.insert(message.author);
}
}
}
if !mentions.is_empty() {
message.mentions.replace(
mentions
.into_iter()
.filter(|id| !user.has_blocked(id))
.collect::<Vec<String>>(),
);
}
if !replies.is_empty() {
message
.replies
.replace(replies.into_iter().collect::<Vec<String>>());
}
// 4. Add attachments to message.
let mut attachments = vec![];
if let Some(ids) = &data.attachments {
if !ids.is_empty() {
permissions
.throw_permission(db, Permission::UploadFiles)
.await?;
}
// ! FIXME: move this to app config
if ids.len() > 5 {
return Err(Error::TooManyAttachments);
}
for attachment_id in ids {
attachments.push(
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id)
.await?,
);
}
}
if !attachments.is_empty() {
message.attachments.replace(attachments);
}
// 5. Process included embeds.
let mut embeds = vec![];
if let Some(sendable_embeds) = data.embeds {
for sendable_embed in sendable_embeds {
embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?)
}
}
if !embeds.is_empty() {
message.embeds.replace(embeds);
}
// 6. Set content
message.content = data.content;
// 7. Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
message.create(db, &channel, Some(&user)).await?;
// Queue up a task for processing embeds
if let Some(content) = &message.content {
revolt_quark::tasks::process_embeds::queue(
channel.id().to_string(),
message.id.to_string(),
content.clone(),
)
.await;
}
Ok(Json(message))
}

View File

@@ -0,0 +1,48 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod channel_ack;
mod channel_delete;
mod channel_edit;
mod channel_fetch;
mod group_add_member;
mod group_create;
mod group_remove_member;
mod invite_create;
mod members_fetch;
mod message_bulk_delete;
mod message_delete;
mod message_edit;
mod message_fetch;
mod message_query;
mod message_query_stale;
mod message_search;
mod message_send;
mod permissions_set;
mod permissions_set_default;
mod voice_join;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
channel_ack::req,
channel_fetch::req,
members_fetch::req,
channel_delete::req,
channel_edit::req,
invite_create::req,
message_send::message_send,
message_query::req,
message_search::req,
message_query_stale::req,
message_fetch::req,
message_edit::req,
message_bulk_delete::req,
message_delete::req,
group_create::req,
group_add_member::req,
group_remove_member::req,
voice_join::req,
permissions_set::req,
permissions_set_default::req,
]
}

View File

@@ -0,0 +1,59 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{Channel, User},
perms, Db, Error, Override, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
pub struct Data {
/// Allow / deny values to set for this role
permissions: Override,
}
/// # Set Role Permission
///
/// Sets permissions for the specified role in this channel.
///
/// Channel must be a `TextChannel` or `VoiceChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/<role_id>", data = "<data>", rank = 2)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
role_id: String,
data: Json<Data>,
) -> Result<Json<Channel>> {
let mut channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::ManagePermissions)
.await?;
if let Some(server) = permissions.server.get() {
if let Some(role) = server.roles.get(&role_id) {
if role.rank <= permissions.get_member_rank().unwrap_or(i64::MIN) {
return Err(Error::NotElevated);
}
let current_value: Override = role.permissions.into();
permissions
.throw_permission_override(db, current_value, data.permissions)
.await?;
channel
.set_role_permission(db, &role_id, data.permissions.into())
.await?;
Ok(Json(channel))
} else {
Err(Error::NotFound)
}
} else {
Err(Error::InvalidOperation)
}
}

View File

@@ -0,0 +1,95 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{channel::PartialChannel, Channel, User},
perms, Db, Error, Override, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum DataDefaultChannelPermissions {
Value {
/// Permission values to set for members in a `Group`
permissions: u64,
},
Field {
/// Allow / deny values to set for members in this `TextChannel` or `VoiceChannel`
permissions: Override,
},
}
/// # Set Default Permission
///
/// Sets permissions for the default role in this channel.
///
/// Channel must be a `Group`, `TextChannel` or `VoiceChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/default", data = "<data>", rank = 1)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
data: Json<DataDefaultChannelPermissions>,
) -> Result<Json<Channel>> {
let data = data.into_inner();
let mut channel = target.as_channel(db).await?;
let mut perm = perms(&user).channel(&channel);
perm.throw_permission_and_view_channel(db, Permission::ManagePermissions)
.await?;
match &channel {
Channel::Group { .. } => {
if let DataDefaultChannelPermissions::Value { permissions } = data {
channel
.update(
db,
PartialChannel {
permissions: Some(permissions as i64),
..Default::default()
},
vec![],
)
.await?;
} else {
return Err(Error::InvalidOperation);
}
}
Channel::TextChannel {
default_permissions,
..
}
| Channel::VoiceChannel {
default_permissions,
..
} => {
if let DataDefaultChannelPermissions::Field { permissions } = data {
perm.throw_permission_override(
db,
default_permissions.map(|x| x.into()),
permissions,
)
.await?;
channel
.update(
db,
PartialChannel {
default_permissions: Some(permissions.into()),
..Default::default()
},
vec![],
)
.await?;
} else {
return Err(Error::InvalidOperation);
}
}
_ => return Err(Error::InvalidOperation),
}
Ok(Json(channel))
}

View File

@@ -0,0 +1,100 @@
use revolt_quark::{
models::{Channel, User},
perms,
variables::delta::{USE_VOSO, VOSO_MANAGE_TOKEN, VOSO_URL},
Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Voice Server Token Response
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct CreateVoiceUserResponse {
/// Token for authenticating with the voice server
token: String,
}
/// # Join Call
///
/// Asks the voice server for a token to join the call.
#[openapi(tag = "Voice")]
#[post("/<target>/join_call")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<CreateVoiceUserResponse>> {
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::Connect)
.await?;
if !*USE_VOSO {
return Err(Error::VosoUnavailable);
}
match channel {
Channel::SavedMessages { .. } | Channel::TextChannel { .. } => {
return Err(Error::CannotJoinCall)
}
_ => {}
}
// To join a call:
// - Check if the room exists.
// - If not, create it.
let client = reqwest::Client::new();
let result = client
.get(&format!("{}/room/{}", *VOSO_URL, channel.id()))
.header(
reqwest::header::AUTHORIZATION,
VOSO_MANAGE_TOKEN.to_string(),
)
.send()
.await;
match result {
Err(_) => return Err(Error::VosoUnavailable),
Ok(result) => match result.status() {
reqwest::StatusCode::OK => (),
reqwest::StatusCode::NOT_FOUND => {
if (client
.post(&format!("{}/room/{}", *VOSO_URL, channel.id()))
.header(
reqwest::header::AUTHORIZATION,
VOSO_MANAGE_TOKEN.to_string(),
)
.send()
.await)
.is_err()
{
return Err(Error::VosoUnavailable);
}
}
_ => return Err(Error::VosoUnavailable),
},
}
// Then create a user for the room.
if let Ok(response) = client
.post(&format!(
"{}/room/{}/user/{}",
*VOSO_URL,
channel.id(),
user.id
))
.header(
reqwest::header::AUTHORIZATION,
VOSO_MANAGE_TOKEN.to_string(),
)
.send()
.await
{
response
.json()
.await
.map_err(|_| Error::InvalidOperation)
.map(Json)
} else {
Err(Error::VosoUnavailable)
}
}

View File

@@ -0,0 +1,31 @@
use revolt_quark::{
models::{Invite, User},
perms, Db, EmptyResponse, Permission, Ref, Result,
};
/// # Delete Invite
///
/// Delete an invite by its id.
#[openapi(tag = "Invites")]
#[delete("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
let invite = target.as_invite(db).await?;
if user.id == invite.creator() {
db.delete_invite(invite.code()).await
} else {
match invite {
Invite::Server { code, server, .. } => {
let server = db.fetch_server(&server).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageServer)
.await?;
db.delete_invite(&code).await
}
_ => unreachable!(),
}
}
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,131 @@
use revolt_quark::models::{Channel, File, Invite};
use revolt_quark::{Db, Ref, Result};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Invite
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Debug, Clone, JsonSchema)]
#[serde(tag = "type")]
pub enum InviteResponse {
/// Server channel invite
Server {
/// Invite code
code: String,
/// Id of the server
server_id: String,
/// Name of the server
server_name: String,
/// Attachment for server icon
#[serde(skip_serializing_if = "Option::is_none")]
server_icon: Option<File>,
/// Attachment for server banner
#[serde(skip_serializing_if = "Option::is_none")]
server_banner: Option<File>,
/// Id of server channel
channel_id: String,
/// Name of server channel
channel_name: String,
/// Description of server channel
#[serde(skip_serializing_if = "Option::is_none")]
channel_description: Option<String>,
/// Name of user who created the invite
user_name: String,
/// Avatar of the user who created the invite
#[serde(skip_serializing_if = "Option::is_none")]
user_avatar: Option<File>,
/// Number of members in this server
member_count: i64,
},
/// Group channel invite
Group {
/// Invite code
code: String,
/// Id of group channel
channel_id: String,
/// Name of group channel
channel_name: String,
/// Description of group channel
#[serde(skip_serializing_if = "Option::is_none")]
channel_description: Option<String>,
/// Name of user who created the invite
user_name: String,
/// Avatar of the user who created the invite
#[serde(skip_serializing_if = "Option::is_none")]
user_avatar: Option<File>,
},
}
/// # Fetch Invite
///
/// Fetch an invite by its id.
#[openapi(tag = "Invites")]
#[get("/<target>")]
pub async fn req(db: &Db, target: Ref) -> Result<Json<InviteResponse>> {
Ok(Json(match target.as_invite(db).await? {
Invite::Server {
channel, creator, ..
} => {
let channel = db.fetch_channel(&channel).await?;
let user = db.fetch_user(&creator).await?;
match channel {
Channel::TextChannel {
id,
server,
name,
description,
..
}
| Channel::VoiceChannel {
id,
server,
name,
description,
..
} => {
let server = db.fetch_server(&server).await?;
InviteResponse::Server {
code: target.id,
member_count: db.fetch_member_count(&server.id).await? as i64,
server_id: server.id,
server_name: server.name,
server_icon: server.icon,
server_banner: server.banner,
channel_id: id,
channel_name: name,
channel_description: description,
user_name: user.username,
user_avatar: user.avatar,
}
}
_ => unreachable!(),
}
}
Invite::Group {
channel, creator, ..
} => {
let channel = db.fetch_channel(&channel).await?;
let user = db.fetch_user(&creator).await?;
match channel {
Channel::Group {
id,
name,
description,
..
} => InviteResponse::Group {
code: target.id,
channel_id: id,
channel_name: name,
channel_description: description,
user_name: user.username,
user_avatar: user.avatar,
},
_ => unreachable!(),
}
}
}))
}

View File

@@ -0,0 +1,47 @@
use revolt_quark::{
models::{Channel, Invite, Server, User},
variables::delta::MAX_SERVER_COUNT,
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Join Response
#[derive(Serialize, JsonSchema)]
#[serde(tag = "type")]
pub enum InviteJoinResponse {
Server {
/// Channels in the server
channels: Vec<Channel>,
/// Server we are joining
server: Server,
},
}
/// # Join Invite
///
/// Join an invite by its ID.
#[openapi(tag = "Invites")]
#[post("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<InviteJoinResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
if !user.can_acquire_server(db).await? {
return Err(Error::TooManyServers {
max: *MAX_SERVER_COUNT,
});
}
let invite = target.as_invite(db).await?;
match &invite {
Invite::Server { server, .. } => {
let server = db.fetch_server(server).await?;
let channels = server.create_member(db, user, None).await?;
Ok(Json(InviteJoinResponse::Server { channels, server }))
}
_ => unreachable!(),
}
}

View File

@@ -0,0 +1,10 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod invite_delete;
mod invite_fetch;
mod invite_join;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![invite_fetch::req, invite_join::req, invite_delete::req]
}

View File

@@ -0,0 +1,267 @@
pub use rocket::http::Status;
pub use rocket::response::Redirect;
use rocket::{Build, Rocket};
use rocket_okapi::{okapi::openapi3::OpenApi, settings::OpenApiSettings};
mod bots;
mod channels;
mod invites;
mod onboard;
mod push;
mod root;
mod servers;
mod sync;
mod users;
pub fn mount(mut rocket: Rocket<Build>) -> Rocket<Build> {
let settings = OpenApiSettings::default();
mount_endpoints_and_merged_docs! {
rocket, "/".to_owned(), settings,
"/" => (vec![], custom_openapi_spec()),
"" => openapi_get_routes_spec![root::root, root::ping],
"/users" => users::routes(),
"/bots" => bots::routes(),
"/channels" => channels::routes(),
"/servers" => servers::routes(),
"/invites" => invites::routes(),
"/auth/account" => rauth::web::account::routes(),
"/auth/session" => rauth::web::session::routes(),
"/onboard" => onboard::routes(),
"/push" => push::routes(),
"/sync" => sync::routes(),
};
rocket
}
fn custom_openapi_spec() -> OpenApi {
use rocket_okapi::okapi::openapi3::*;
let mut extensions = schemars::Map::new();
extensions.insert(
"x-logo".to_owned(),
json!({
"url": "https://revolt.chat/header.png",
"altText": "Revolt Header"
}),
);
extensions.insert(
"x-tagGroups".to_owned(),
json!([
{
"name": "Revolt",
"tags": [
"Core"
]
},
{
"name": "Users",
"tags": [
"User Information",
"Direct Messaging",
"Relationships"
]
},
{
"name": "Bots",
"tags": [
"Bots"
]
},
{
"name": "Channels",
"tags": [
"Channel Information",
"Channel Invites",
"Channel Permissions",
"Messaging",
"Groups",
"Voice"
]
},
{
"name": "Servers",
"tags": [
"Server Information",
"Server Members",
"Server Permissions"
]
},
{
"name": "Invites",
"tags": [
"Invites"
]
},
{
"name": "Authentication",
"tags": [
"Account",
"Session",
"Onboarding"
]
},
{
"name": "Miscellaneous",
"tags": [
"Sync",
"Web Push"
]
}
]),
);
OpenApi {
openapi: OpenApi::default_version(),
info: Info {
title: "Revolt API".to_owned(),
description: Some("User-first privacy focused chat platform.".to_owned()),
terms_of_service: Some("https://revolt.chat/terms".to_owned()),
contact: Some(Contact {
name: Some("Revolt Support".to_owned()),
url: Some("https://revolt.chat".to_owned()),
email: Some("contact@revolt.chat".to_owned()),
..Default::default()
}),
license: Some(License {
name: "AGPLv3".to_owned(),
url: Some("https://github.com/revoltchat/delta/blob/master/LICENSE".to_owned()),
..Default::default()
}),
version: "0.5.3-rc.1".to_owned(),
..Default::default()
},
servers: vec![
Server {
url: "https://api.revolt.chat".to_owned(),
description: Some("Revolt API".to_owned()),
..Default::default()
},
Server {
url: "http://local.revolt.chat:8000".to_owned(),
description: Some("Local Revolt Environment".to_owned()),
..Default::default()
},
],
external_docs: Some(ExternalDocs {
url: "https://developers.revolt.chat".to_owned(),
description: Some("Revolt Developer Documentation".to_owned()),
..Default::default()
}),
extensions,
tags: vec![
Tag {
name: "Core".to_owned(),
description: Some(
"Use in your applications to determine information about the Revolt node"
.to_owned(),
),
..Default::default()
},
Tag {
name: "User Information".to_owned(),
description: Some("Query and fetch users on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Direct Messaging".to_owned(),
description: Some("Direct message other users on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Relationships".to_owned(),
description: Some(
"Manage your friendships and block list on the platform".to_owned(),
),
..Default::default()
},
Tag {
name: "Bots".to_owned(),
description: Some("Create and edit bots".to_owned()),
..Default::default()
},
Tag {
name: "Channel Information".to_owned(),
description: Some("Query and fetch channels on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Channel Invites".to_owned(),
description: Some("Create and manage invites for channels".to_owned()),
..Default::default()
},
Tag {
name: "Channel Permissions".to_owned(),
description: Some("Manage permissions for channels".to_owned()),
..Default::default()
},
Tag {
name: "Messaging".to_owned(),
description: Some("Send and manipulate messages".to_owned()),
..Default::default()
},
Tag {
name: "Groups".to_owned(),
description: Some("Create, invite users and manipulate groups".to_owned()),
..Default::default()
},
Tag {
name: "Voice".to_owned(),
description: Some("Join and talk with other users".to_owned()),
..Default::default()
},
Tag {
name: "Server Information".to_owned(),
description: Some("Query and fetch servers on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Server Members".to_owned(),
description: Some("Find and edit server members".to_owned()),
..Default::default()
},
Tag {
name: "Server Permissions".to_owned(),
description: Some("Manage permissions for servers".to_owned()),
..Default::default()
},
Tag {
name: "Invites".to_owned(),
description: Some("View, join and delete invites".to_owned()),
..Default::default()
},
Tag {
name: "Account".to_owned(),
description: Some("Manage your account".to_owned()),
..Default::default()
},
Tag {
name: "Session".to_owned(),
description: Some("Create and manage sessions".to_owned()),
..Default::default()
},
Tag {
name: "Onboarding".to_owned(),
description: Some(
"After signing up to Revolt, users must pick a unique username".to_owned(),
),
..Default::default()
},
Tag {
name: "Sync".to_owned(),
description: Some("Upload and retrieve any JSON data between clients".to_owned()),
..Default::default()
},
Tag {
name: "Web Push".to_owned(),
description: Some(
"Subscribe to and receive Revolt push notifications while offline".to_owned(),
),
..Default::default()
},
],
..Default::default()
}
}

View File

@@ -0,0 +1,47 @@
use crate::util::regex::RE_USERNAME;
use revolt_quark::{models::User, Database, EmptyResponse, Error, Result};
use rauth::entities::Session;
use rocket::{serde::json::Json, State};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # New User Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataOnboard {
/// New username which will be used to identify the user on the platform
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
username: String,
}
/// # Complete Onboarding
///
/// This sets a new username, completes onboarding and allows a user to start using Revolt.
#[openapi(tag = "Onboarding")]
#[post("/complete", data = "<data>")]
pub async fn req(
db: &State<Database>,
session: Session,
user: Option<User>,
data: Json<DataOnboard>,
) -> Result<EmptyResponse> {
if user.is_some() {
return Err(Error::AlreadyOnboarded);
}
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
if db.is_username_taken(&data.username).await? {
return Err(Error::UsernameTaken);
}
let user = User {
id: session.user_id,
username: data.username,
..Default::default()
};
db.insert_user(&user).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,22 @@
use rauth::entities::Session;
use revolt_quark::models::User;
use rocket::serde::json::Json;
use serde::Serialize;
/// # Onboarding Status
#[derive(Serialize, JsonSchema)]
pub struct DataHello {
/// Whether onboarding is required
onboarding: bool,
}
/// # Check Onboarding Status
///
/// This will tell you whether the current account requires onboarding or whether you can continue to send requests as usual. You may skip calling this if you're restoring an existing session.
#[openapi(tag = "Onboarding")]
#[get("/hello")]
pub async fn req(_session: Session, user: Option<User>) -> Json<DataHello> {
Json(DataHello {
onboarding: user.is_none(),
})
}

View File

@@ -0,0 +1,9 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod complete;
mod hello;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![hello::req, complete::req]
}

View File

@@ -0,0 +1,9 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod subscribe;
mod unsubscribe;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![subscribe::req, unsubscribe::req]
}

View File

@@ -0,0 +1,30 @@
use revolt_quark::{EmptyResponse, Error, Result};
use rauth::{
entities::{Model, Session, WebPushSubscription},
logic::Auth,
};
use rocket::{serde::json::Json, State};
/// # Push Subscribe
///
/// Create a new Web Push subscription.
///
/// If an existing subscription exists on this session, it will be removed.
#[openapi(tag = "Web Push")]
#[post("/subscribe", data = "<data>")]
pub async fn req(
auth: &State<Auth>,
mut session: Session,
data: Json<WebPushSubscription>,
) -> Result<EmptyResponse> {
session.subscription = Some(data.into_inner());
session
.save(&auth.db, None)
.await
.map(|_| EmptyResponse)
.map_err(|_| Error::DatabaseError {
operation: "save",
with: "session",
})
}

View File

@@ -0,0 +1,24 @@
use revolt_quark::{EmptyResponse, Error, Result};
use rauth::{
entities::{Model, Session},
logic::Auth,
};
use rocket::State;
/// # Unsubscribe
///
/// Remove the Web Push subscription associated with the current session.
#[openapi(tag = "Web Push")]
#[post("/unsubscribe")]
pub async fn req(auth: &State<Auth>, mut session: Session) -> Result<EmptyResponse> {
session.subscription = None;
session
.save(&auth.db, None)
.await
.map(|_| EmptyResponse)
.map_err(|_| Error::DatabaseError {
operation: "save",
with: "session",
})
}

View File

@@ -0,0 +1,112 @@
use revolt_quark::variables::delta::{
APP_URL, AUTUMN_URL, EXTERNAL_WS_URL, HCAPTCHA_SITEKEY, INVITE_ONLY, JANUARY_URL, USE_AUTUMN,
USE_EMAIL, USE_HCAPTCHA, USE_JANUARY, USE_VOSO, VAPID_PUBLIC_KEY, VOSO_URL, VOSO_WS_HOST,
};
use revolt_quark::Result;
use rocket::http::Status;
use rocket::serde::json::Json;
use serde::Serialize;
/// # hCaptcha Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct CaptchaFeature {
/// Whether captcha is enabled
pub enabled: bool,
/// Client key used for solving captcha
pub key: String,
}
/// # Generic Service Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct Feature {
/// Whether the service is enabled
pub enabled: bool,
/// URL pointing to the service
pub url: String,
}
/// # Voice Server Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct VoiceFeature {
/// Whether voice is enabled
pub enabled: bool,
/// URL pointing to the voice API
pub url: String,
/// URL pointing to the voice WebSocket server
pub ws: String,
}
/// # Feature Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct RevoltFeatures {
/// hCaptcha configuration
pub captcha: CaptchaFeature,
/// Whether email verification is enabled
pub email: bool,
/// Whether this server is invite only
pub invite_only: bool,
/// File server service configuration
pub autumn: Feature,
/// Proxy service configuration
pub january: Feature,
/// Voice server configuration
pub voso: VoiceFeature,
}
/// # Server Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct RevoltConfig {
/// Revolt API Version
pub revolt: String,
/// Features enabled on this Revolt node
pub features: RevoltFeatures,
/// WebSocket URL
pub ws: String,
/// URL pointing to the client serving this node
pub app: String,
/// Web Push VAPID public key
pub vapid: String,
}
/// # Query Node
///
/// Fetch the server configuration for this Revolt instance.
#[openapi(tag = "Core")]
#[get("/")]
pub async fn root() -> Result<Json<RevoltConfig>> {
Ok(Json(RevoltConfig {
revolt: crate::version::VERSION.to_string(),
features: RevoltFeatures {
captcha: CaptchaFeature {
enabled: *USE_HCAPTCHA,
key: HCAPTCHA_SITEKEY.to_string(),
},
email: *USE_EMAIL,
invite_only: *INVITE_ONLY,
autumn: Feature {
enabled: *USE_AUTUMN,
url: AUTUMN_URL.to_string(),
},
january: Feature {
enabled: *USE_JANUARY,
url: JANUARY_URL.to_string(),
},
voso: VoiceFeature {
enabled: *USE_VOSO,
url: VOSO_URL.to_string(),
ws: VOSO_WS_HOST.to_string(),
},
},
ws: EXTERNAL_WS_URL.to_string(),
app: APP_URL.to_string(),
vapid: VAPID_PUBLIC_KEY.to_string(),
}))
}
/// Example endpoint.
#[openapi(skip)]
#[get("/ping")]
pub async fn ping(/*_limitguard: Ratelimiter*/) -> Status {
Status::Ok
}

View File

@@ -0,0 +1,59 @@
use revolt_quark::{
models::{ServerBan, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Ban Information
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataBanCreate {
/// Ban reason
#[validate(length(min = 1, max = 1024))]
reason: Option<String>,
}
/// # Ban User
///
/// Ban a user by their id.
#[openapi(tag = "Server Members")]
#[put("/<server>/bans/<target>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
server: Ref,
target: Ref,
data: Json<DataBanCreate>,
) -> Result<Json<ServerBan>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let server = server.as_server(db).await?;
if target.id == user.id {
return Err(Error::CannotRemoveYourself);
}
if target.id == server.owner {
return Err(Error::InvalidOperation);
}
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::BanMembers)
.await?;
let member = target.as_member(db, &server.id).await?;
if member.get_ranking(permissions.server.get().unwrap())
<= permissions.get_member_rank().unwrap_or(i64::MIN)
{
return Err(Error::NotElevated);
}
server.ban_member(db, member, data.reason).await.map(Json)
}

View File

@@ -0,0 +1,67 @@
use revolt_quark::models::{File, ServerBan, User};
use revolt_quark::{perms, Db, Permission, Ref, Result};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Banned User
///
/// Just enoguh user information to list bans.
#[derive(Serialize, Deserialize, JsonSchema)]
struct BannedUser {
/// Id of the banned user
#[serde(rename = "_id")]
pub id: String,
/// Username of the banned user
pub username: String,
/// Avatar of the banned user
pub avatar: Option<File>,
}
/// # Ban List Result
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct BanListResult {
/// Users objects
users: Vec<BannedUser>,
/// Ban objects
bans: Vec<ServerBan>,
}
impl From<User> for BannedUser {
fn from(user: User) -> Self {
BannedUser {
id: user.id,
username: user.username,
avatar: user.avatar,
}
}
}
/// # Fetch Bans
///
/// Fetch all bans on a server.
#[openapi(tag = "Server Members")]
#[get("/<target>/bans")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<BanListResult>> {
let server = target.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::BanMembers)
.await?;
let bans = db.fetch_bans(&server.id).await?;
let users = db
.fetch_users(
&bans
.iter()
.map(|x| &x.id.user)
.cloned()
.collect::<Vec<String>>(),
)
.await?
.into_iter()
.map(|x| x.into())
.collect();
Ok(Json(BanListResult { users, bans }))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
/// # Unban user
///
/// Remove a user's ban.
#[openapi(tag = "Server Members")]
#[delete("/<server>/bans/<target>")]
pub async fn req(db: &Db, user: User, server: Ref, target: Ref) -> Result<EmptyResponse> {
let server = server.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::BanMembers)
.await?;
let ban = target.as_ban(db, &server.id).await?;
db.delete_ban(&ban.id).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,120 @@
use std::collections::HashMap;
use revolt_quark::{
models::{server::PartialServer, Channel, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
/// # Channel Type
#[derive(Serialize, Deserialize, JsonSchema)]
enum ChannelType {
/// Text Channel
Text,
/// Voice Channel
Voice,
}
impl Default for ChannelType {
fn default() -> Self {
ChannelType::Text
}
}
/// # Channel Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataCreateChannel {
/// Channel type
#[serde(rename = "type", default = "ChannelType::default")]
channel_type: ChannelType,
/// Channel name
#[validate(length(min = 1, max = 32))]
name: String,
/// Channel description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Whether this channel is age restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
}
/// # Create Channel
///
/// Create a new Text or Voice channel.
#[openapi(tag = "Server Information")]
#[post("/<target>/channels", data = "<info>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
info: Json<DataCreateChannel>,
) -> Result<Json<Channel>> {
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut server = target.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageChannel)
.await?;
let id = Ulid::new().to_string();
let mut channels = server.channels.clone();
channels.push(id.clone());
let DataCreateChannel {
name,
description,
nsfw,
channel_type,
} = info;
let channel = match channel_type {
ChannelType::Text => Channel::TextChannel {
id,
server: server.id.clone(),
name,
description,
icon: None,
last_message_id: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: nsfw.unwrap_or(false),
},
ChannelType::Voice => Channel::VoiceChannel {
id,
server: server.id.clone(),
name,
description,
icon: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: nsfw.unwrap_or(false),
},
};
channel.create(db).await?;
server
.update(
db,
PartialServer {
channels: Some(channels),
..Default::default()
},
vec![],
)
.await?;
Ok(Json(channel))
}

View File

@@ -0,0 +1,25 @@
use revolt_quark::{
models::{Invite, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Invites
///
/// Fetch all server invites.
#[openapi(tag = "Server Members")]
#[get("/<target>/invites")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Vec<Invite>>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let server = target.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageServer)
.await?;
db.fetch_invites_for_server(&server.id).await.map(Json)
}

View File

@@ -0,0 +1,84 @@
use revolt_quark::{
models::{
server_member::{FieldsMember, PartialMember},
File, Member, User,
},
perms, Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Member Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataMemberEdit {
/// Member nickname
#[validate(length(min = 1, max = 32))]
nickname: Option<String>,
/// Attachment Id to set for avatar
avatar: Option<String>,
/// Array of role ids
roles: Option<Vec<String>>,
/// Fields to remove from channel object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsMember>>,
}
/// # Edit Member
///
/// Edit a member by their id.
#[openapi(tag = "Server Members")]
#[patch("/<server>/members/<target>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
server: Ref,
target: Ref,
data: Json<DataMemberEdit>,
) -> Result<Json<Member>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let server = server.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
let mut member = target.as_member(db, &server.id).await?;
let DataMemberEdit {
nickname,
avatar,
roles,
remove,
} = data;
let mut partial = PartialMember {
nickname,
roles,
..Default::default()
};
// ! FIXME: calculate permission against member
// ! FIXME: also check roles exist lol
// 1. Remove fields from object
if let Some(fields) = &remove {
if fields.contains(&FieldsMember::Avatar) {
if let Some(avatar) = &member.avatar {
db.mark_attachment_as_deleted(&avatar.id).await?;
}
}
}
// 2. Apply new avatar
if let Some(avatar) = avatar {
partial.avatar = Some(File::use_avatar(db, &avatar, &user.id).await?);
}
member
.update(db, partial, remove.unwrap_or_default())
.await?;
Ok(Json(member))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::{
models::{Member, User},
perms, Db, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Member
///
/// Retrieve a member.
#[openapi(tag = "Server Members")]
#[get("/<target>/members/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<Json<Member>> {
let server = target.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
member.as_member(db, &server.id).await.map(Json)
}

View File

@@ -0,0 +1,73 @@
use revolt_quark::{
models::{Member, User},
perms,
presence::presence_filter_online,
Db, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Query Parameters
#[derive(Deserialize, JsonSchema, FromForm)]
pub struct OptionsFetchAllMembers {
/// Whether to exclude offline users
exclude_offline: Option<bool>,
}
/// # Member List
///
/// Both lists are sorted by ID.
#[derive(Serialize, JsonSchema)]
pub struct AllMemberResponse {
/// List of members
members: Vec<Member>,
/// List of users
users: Vec<User>,
}
/// # Fetch Members
///
/// Fetch all server members.
#[openapi(tag = "Server Members")]
#[get("/<target>/members?<options..>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: OptionsFetchAllMembers,
) -> Result<Json<AllMemberResponse>> {
let server = target.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
let mut members = db.fetch_all_members(&server.id).await?;
let mut user_ids = vec![];
for member in &members {
user_ids.push(member.id.user.clone());
}
let online_ids = presence_filter_online(&user_ids).await;
let mut users = db
.fetch_users(&user_ids)
.await?
.into_iter()
.map(|mut user| {
user.online = Some(online_ids.contains(&user.id));
user.foreign()
})
.collect::<Vec<User>>();
// Ensure the lists match up exactly.
members.sort_by(|a, b| a.id.user.cmp(&b.id.user));
users.sort_by(|a, b| a.id.cmp(&b.id));
// Optionally, remove all offline user entries.
if let Some(true) = options.exclude_offline {
let mut iter = users.iter();
members.retain(|_| iter.next().unwrap().online.unwrap_or(false));
users.retain(|user| user.online.unwrap_or(false));
}
Ok(Json(AllMemberResponse { members, users }))
}

View File

@@ -0,0 +1,40 @@
use revolt_quark::{
models::{server_member::RemovalIntention, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Kick Member
///
/// Removes a member from the server.
#[openapi(tag = "Server Members")]
#[delete("/<target>/members/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<EmptyResponse> {
let server = target.as_server(db).await?;
if member.id == user.id {
return Err(Error::CannotRemoveYourself);
}
if member.id == server.owner {
return Err(Error::InvalidOperation);
}
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::KickMembers)
.await?;
let member = member.as_member(db, &server.id).await?;
if member.get_ranking(permissions.server.get().unwrap())
<= permissions.get_member_rank().unwrap_or(i64::MIN)
{
return Err(Error::NotElevated);
}
server
.remove_member(db, member, RemovalIntention::Kick)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,51 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod server_ack;
mod server_create;
mod server_delete;
mod server_edit;
mod server_fetch;
mod channel_create;
mod member_edit;
mod member_fetch;
mod member_fetch_all;
mod member_remove;
mod ban_create;
mod ban_list;
mod ban_remove;
mod invites_fetch;
mod permissions_set;
mod permissions_set_default;
mod roles_create;
mod roles_delete;
mod roles_edit;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
server_create::req,
server_delete::req,
server_fetch::req,
server_edit::req,
server_ack::req,
channel_create::req,
member_fetch_all::req,
member_remove::req,
member_fetch::req,
member_edit::req,
ban_create::req,
ban_remove::req,
ban_list::req,
invites_fetch::req,
roles_create::req,
roles_edit::req,
roles_delete::req,
permissions_set::req,
permissions_set_default::req
]
}

View File

@@ -0,0 +1,56 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{Server, User},
perms, Db, Error, Override, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
pub struct DataSetServerRolePermission {
/// Allow / deny values for the role in this server.
permissions: Override,
}
/// # Set Role Permission
///
/// Sets permissions for the specified role in the server.
#[openapi(tag = "Server Permissions")]
#[put("/<target>/permissions/<role_id>", data = "<data>", rank = 2)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
role_id: String,
data: Json<DataSetServerRolePermission>,
) -> Result<Json<Server>> {
let data = data.into_inner();
let mut server = target.as_server(db).await?;
if let Some((current_value, rank)) = server.roles.get(&role_id).map(|x| (x.permissions, x.rank))
{
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::ManagePermissions)
.await?;
if rank <= permissions.get_member_rank().unwrap_or(i64::MIN) {
return Err(Error::NotElevated);
}
let current_value: Override = current_value.into();
permissions
.throw_permission_override(db, current_value, data.permissions)
.await?;
server
.set_role_permission(db, &role_id, data.permissions.into())
.await?;
Ok(Json(server))
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,52 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{server::PartialServer, Server, User},
perms, Db, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
pub struct DataSetServerDefaultPermission {
/// Default member permission value
permissions: u64,
}
/// # Set Default Permission
///
/// Sets permissions for the default role in this server.
#[openapi(tag = "Server Permissions")]
#[put("/<target>/permissions/default", data = "<data>", rank = 1)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
data: Json<DataSetServerDefaultPermission>,
) -> Result<Json<Server>> {
let data = data.into_inner();
let mut server = target.as_server(db).await?;
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::ManagePermissions)
.await?;
permissions
.throw_permission_value(db, data.permissions)
.await?;
server
.update(
db,
PartialServer {
default_permissions: Some(data.permissions as i64),
..Default::default()
},
vec![],
)
.await?;
Ok(Json(server))
}

View File

@@ -0,0 +1,74 @@
use revolt_quark::{
models::{server::Role, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Role Data
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataCreateRole {
/// Role name
#[validate(length(min = 1, max = 32))]
name: String,
/// Ranking position
///
/// Smaller values take priority.
rank: Option<i64>,
}
/// # New Role Response
#[derive(Serialize, JsonSchema)]
pub struct NewRoleResponse {
/// Id of the role
id: String,
/// New role
role: Role,
}
/// # Create Role
///
/// Creates a new server role.
#[openapi(tag = "Server Permissions")]
#[post("/<target>/roles", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
data: Json<DataCreateRole>,
) -> Result<Json<NewRoleResponse>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let server = target.as_server(db).await?;
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::ManageRole)
.await?;
let member_rank = permissions.get_member_rank();
let rank = if let Some(given_rank) = data.rank {
if given_rank <= member_rank.unwrap_or(i64::MIN) {
return Err(Error::NotElevated);
}
given_rank
} else {
member_rank.unwrap_or(0).saturating_add(1)
};
let role = Role {
name: data.name,
rank,
..Default::default()
};
Ok(Json(NewRoleResponse {
id: role.create(db, &server.id).await?,
role,
}))
}

View File

@@ -0,0 +1,29 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Delete Role
///
/// Delete a server role by its id.
#[openapi(tag = "Server Permissions")]
#[delete("/<target>/roles/<role_id>")]
pub async fn req(db: &Db, user: User, target: Ref, role_id: String) -> Result<EmptyResponse> {
let mut server = target.as_server(db).await?;
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::ManageRole)
.await?;
let member_rank = permissions.get_member_rank().unwrap_or(0);
if let Some(role) = server.roles.remove(&role_id) {
if role.rank <= member_rank {
return Err(Error::NotElevated);
}
role.delete(db, &server.id, &role_id)
.await
.map(|_| EmptyResponse)
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,94 @@
use revolt_quark::{
models::{
server::{FieldsRole, PartialRole, Role},
User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Role Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditRole {
/// Role name
#[validate(length(min = 1, max = 32))]
name: Option<String>,
/// Role colour
#[validate(length(min = 1, max = 32))]
colour: Option<String>,
/// Whether this role should be displayed separately
hoist: Option<bool>,
/// Ranking position
///
/// Smaller values take priority.
rank: Option<i64>,
/// Fields to remove from role object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsRole>>,
}
/// # Edit Role
///
/// Edit a role by its id.
#[openapi(tag = "Server Permissions")]
#[patch("/<target>/roles/<role_id>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
role_id: String,
data: Json<DataEditRole>,
) -> Result<Json<Role>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut server = target.as_server(db).await?;
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::ManageRole)
.await?;
let member_rank = permissions.get_member_rank().unwrap_or(i64::MIN);
if let Some(mut role) = server.roles.remove(&role_id) {
let DataEditRole {
name,
colour,
hoist,
rank,
remove,
} = data;
if let Some(rank) = &rank {
if rank <= &member_rank {
return Err(Error::NotElevated);
}
}
let partial = PartialRole {
name,
colour,
hoist,
rank,
..Default::default()
};
role.update(
db,
&server.id,
&role_id,
partial,
remove.unwrap_or_default(),
)
.await?;
Ok(Json(role))
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,19 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Ref, Result};
/// # Mark Server As Read
///
/// Mark all channels in a server as read.
#[openapi(tag = "Server Information")]
#[put("/<target>/ack")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let server = target.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
db.acknowledge_channels(&user.id, &server.channels)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,98 @@
use std::collections::HashMap;
use revolt_quark::{
models::{Channel, Server, User},
variables::delta::MAX_SERVER_COUNT,
Db, Error, Result, DEFAULT_PERMISSION_SERVER,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
/// # Server Data
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataCreateServer {
/// Server name
#[validate(length(min = 1, max = 32))]
name: String,
/// Server description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Whether this server is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
}
/// # Create Server Response
#[derive(Validate, Serialize, JsonSchema)]
pub struct CreateServerResponse {
/// Server object
server: Server,
/// Default channels
channels: Vec<Channel>,
}
/// # Create Server
///
/// Create a new server.
#[openapi(tag = "Server Information")]
#[post("/create", data = "<info>")]
pub async fn req(
db: &Db,
user: User,
info: Json<DataCreateServer>,
) -> Result<Json<CreateServerResponse>> {
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
if !user.can_acquire_server(db).await? {
return Err(Error::TooManyServers {
max: *MAX_SERVER_COUNT,
});
}
let DataCreateServer {
name,
description,
nsfw,
} = info;
let channel_id = Ulid::new().to_string();
let server_id = Ulid::new().to_string();
let channel = Channel::TextChannel {
id: channel_id.clone(),
server: server_id.clone(),
name: "General".into(),
description: None,
icon: None,
last_message_id: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: nsfw.unwrap_or(false),
};
db.insert_channel(&channel).await?;
let server = Server {
id: server_id.clone(),
owner: user.id.clone(),
name,
description,
channels: vec![channel_id],
nsfw: nsfw.unwrap_or(false),
default_permissions: *DEFAULT_PERMISSION_SERVER as i64,
..Default::default()
};
server.create(db).await?;
let channels = server.create_member(db, user, Some(vec![channel])).await?;
Ok(Json(CreateServerResponse { server, channels }))
}

View File

@@ -0,0 +1,23 @@
use revolt_quark::{
models::{server_member::RemovalIntention, User},
Db, EmptyResponse, Ref, Result,
};
/// # Delete / Leave Server
///
/// Deletes a server if owner otherwise leaves.
#[openapi(tag = "Server Information")]
#[delete("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
let server = target.as_server(db).await?;
let member = db.fetch_member(&target.id, &user.id).await?;
if server.owner == user.id {
server.delete(db).await
} else {
server
.remove_member(db, member, RemovalIntention::Leave)
.await
}
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,162 @@
use revolt_quark::{
models::{
server::{Category, FieldsServer, PartialServer, SystemMessageChannels},
File, Server, User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Server Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditServer {
/// Server name
#[validate(length(min = 1, max = 32))]
name: Option<String>,
/// Server description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Attachment Id for icon
icon: Option<String>,
/// Attachment Id for banner
banner: Option<String>,
/// Category structure for server
#[validate]
categories: Option<Vec<Category>>,
/// System message configuration
system_messages: Option<SystemMessageChannels>,
// Whether this server is age-restricted
// nsfw: Option<bool>,
/// Whether analytics should be collected for this server
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
analytics: Option<bool>,
/// Fields to remove from server object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsServer>>,
}
/// # Edit Server
///
/// Edit a server by its id.
#[openapi(tag = "Server Information")]
#[patch("/<target>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
data: Json<DataEditServer>,
) -> Result<Json<Server>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut server = target.as_server(db).await?;
let mut permissions = perms(&user).server(&server);
permissions.calc(db).await?;
// Check permissions
if data.name.is_none()
&& data.description.is_none()
&& data.icon.is_none()
&& data.banner.is_none()
&& data.system_messages.is_none()
&& data.categories.is_none()
// && data.nsfw.is_none()
&& data.analytics.is_none()
&& data.remove.is_none()
{
return Ok(Json(server));
} else if data.name.is_some()
|| data.description.is_some()
|| data.icon.is_some()
|| data.banner.is_some()
|| data.system_messages.is_some()
// || data.nsfw.is_some()
|| data.analytics.is_some()
|| data.remove.is_some()
{
permissions
.throw_permission(db, Permission::ManageServer)
.await?;
}
if data.categories.is_some() {
permissions
.throw_permission(db, Permission::ManageChannel)
.await?;
}
let DataEditServer {
name,
description,
icon,
banner,
categories,
system_messages,
// nsfw,
analytics,
remove,
} = data;
let mut partial = PartialServer {
name,
description,
categories,
system_messages,
// nsfw,
analytics,
..Default::default()
};
// 1. Remove fields from object
if let Some(fields) = &remove {
if fields.contains(&FieldsServer::Banner) {
if let Some(banner) = &server.banner {
db.mark_attachment_as_deleted(&banner.id).await?;
}
}
if fields.contains(&FieldsServer::Icon) {
if let Some(icon) = &server.icon {
db.mark_attachment_as_deleted(&icon.id).await?;
}
}
}
// 2. Apply new icon
if let Some(icon) = icon {
partial.icon = Some(File::use_server_icon(db, &icon, &server.id).await?);
server.icon = partial.icon.clone();
}
// 3. Apply new banner
if let Some(banner) = banner {
partial.banner = Some(File::use_banner(db, &banner, &server.id).await?);
server.banner = partial.banner.clone();
}
// 4. Validate changes
if let Some(system_messages) = &partial.system_messages {
let channels = system_messages.clone().into_channel_ids();
if !db
.check_channels_exist(&channels.into_iter().collect())
.await?
{
return Err(Error::NotFound);
}
}
server
.update(db, partial, remove.unwrap_or_default())
.await?;
Ok(Json(server))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::{
models::{Server, User},
perms, Db, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Server
///
/// Fetch a server by its id.
#[openapi(tag = "Server Information")]
#[get("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Server>> {
let server = target.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
Ok(Json(server))
}

View File

@@ -0,0 +1,31 @@
use revolt_quark::{
models::{User, UserSettings},
Db, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Fetch Options
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct OptionsFetchSettings {
/// Keys to fetch
keys: Vec<String>,
}
/// # Fetch Settings
///
/// Fetch settings from server filtered by keys.
///
/// This will return an object with the requested keys, each value is a tuple of `(timestamp, value)`, the value is the previously uploaded data.
#[openapi(tag = "Sync")]
#[post("/settings/fetch", data = "<options>")]
pub async fn req(
db: &Db,
user: User,
options: Json<OptionsFetchSettings>,
) -> Result<Json<UserSettings>> {
db.fetch_user_settings(&user.id, &options.into_inner().keys)
.await
.map(Json)
}

View File

@@ -0,0 +1,15 @@
use revolt_quark::{
models::{ChannelUnread, User},
Db, Result,
};
use rocket::serde::json::Json;
/// # Fetch Unreads
///
/// Fetch information about unread state on channels.
#[openapi(tag = "Sync")]
#[get("/unreads")]
pub async fn req(db: &Db, user: User) -> Result<Json<Vec<ChannelUnread>>> {
db.fetch_unreads(&user.id).await.map(Json)
}

View File

@@ -0,0 +1,10 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod get_settings;
mod get_unreads;
mod set_settings;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![get_settings::req, set_settings::req, get_unreads::req]
}

View File

@@ -0,0 +1,50 @@
use revolt_quark::models::User;
use revolt_quark::r#impl::generic::users::user_settings::UserSettingsImpl;
use revolt_quark::{Db, EmptyResponse, Result};
use chrono::prelude::*;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
type Data = HashMap<String, String>;
/// # Set Options
#[derive(FromForm, Serialize, Deserialize, JsonSchema)]
pub struct OptionsSetSettings {
/// Timestamp of settings change.
///
/// Used to avoid feedback loops.
timestamp: Option<i64>,
}
/// # Set Settings
///
/// Upload data to save to settings.
#[openapi(tag = "Sync")]
#[post("/settings/set?<options..>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
data: Json<Data>,
options: OptionsSetSettings,
) -> Result<EmptyResponse> {
let data = data.into_inner();
let current_time = Utc::now().timestamp_millis();
let timestamp = if let Some(timestamp) = options.timestamp {
if timestamp > current_time {
current_time
} else {
timestamp
}
} else {
current_time
};
let mut settings = HashMap::new();
for (key, data) in data {
settings.insert(key, (timestamp, data));
}
settings.set(db, &user.id).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,21 @@
use revolt_quark::models::User;
use revolt_quark::{Database, Error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Send Friend Request / Accept Request
///
/// Send a friend request to another user or accept another user's friend request.
#[openapi(tag = "Relationships")]
#[put("/<username>/friend")]
pub async fn req(db: &State<Database>, user: User, username: String) -> Result<Json<User>> {
let mut target = db.fetch_user_by_username(&username).await?;
if user.bot.is_some() || target.bot.is_some() {
return Err(Error::IsBot);
}
user.add_friend(db, &mut target).await?;
Ok(Json(target.with_auto_perspective(db, &user).await))
}

View File

@@ -0,0 +1,16 @@
use revolt_quark::models::User;
use revolt_quark::{Database, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Block User
///
/// Block another user by their id.
#[openapi(tag = "Relationships")]
#[put("/<target>/block")]
pub async fn req(db: &State<Database>, user: User, target: String) -> Result<Json<User>> {
let mut target = db.fetch_user(&target).await?;
user.block_user(db, &mut target).await?;
Ok(Json(target.with_auto_perspective(db, &user).await))
}

View File

@@ -0,0 +1,40 @@
use crate::util::regex::RE_USERNAME;
use rauth::entities::Account;
use revolt_quark::{models::User, Database, Error, Result};
use rocket::{serde::json::Json, State};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Username Information
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataChangeUsername {
/// New username
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
username: String,
/// Current account password
#[validate(length(min = 8, max = 1024))]
password: String,
}
/// # Change Username
///
/// Change your username.
#[openapi(tag = "User Information")]
#[patch("/@me/username", data = "<data>")]
pub async fn req(
db: &State<Database>,
account: Account,
mut user: User,
data: Json<DataChangeUsername>,
) -> Result<Json<User>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
account
.verify_password(&data.password)
.map_err(|_| Error::InvalidCredentials)?;
user.update_username(db, data.username).await?;
Ok(Json(user.foreign()))
}

View File

@@ -0,0 +1,125 @@
use revolt_quark::models::user::{FieldsUser, PartialUser, User};
use revolt_quark::models::File;
use revolt_quark::{Database, Error, Result};
use revolt_quark::models::user::UserStatus;
use rocket::serde::json::Json;
use rocket::State;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Profile Data
#[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)]
pub struct UserProfileData {
/// Text to set as user profile description
#[validate(length(min = 0, max = 2000))]
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
/// Attachment Id for background
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 128))]
background: Option<String>,
}
/// # User Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditUser {
/// New user status
#[validate]
status: Option<UserStatus>,
/// New user profile data
///
/// This is applied as a partial.
#[validate]
profile: Option<UserProfileData>,
/// Attachment Id for avatar
#[validate(length(min = 1, max = 128))]
avatar: Option<String>,
/// Fields to remove from user object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsUser>>,
}
/// # Edit User
///
/// Edit currently authenticated user.
#[openapi(tag = "User Information")]
#[patch("/@me", data = "<data>")]
pub async fn req(
db: &State<Database>,
mut user: User,
data: Json<DataEditUser>,
) -> Result<Json<User>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
if data.status.is_none()
&& data.profile.is_none()
&& data.avatar.is_none()
&& data.remove.is_none()
{
return Ok(Json(user));
}
// 1. Remove fields from object
if let Some(fields) = &data.remove {
if fields.contains(&FieldsUser::Avatar) {
if let Some(avatar) = &user.avatar {
db.mark_attachment_as_deleted(&avatar.id).await?;
}
}
if fields.contains(&FieldsUser::ProfileBackground) {
if let Some(profile) = &user.profile {
if let Some(background) = &profile.background {
db.mark_attachment_as_deleted(&background.id).await?;
}
}
}
for field in fields {
user.remove(field);
}
}
let mut partial: PartialUser = Default::default();
// 2. Apply new avatar
if let Some(avatar) = data.avatar {
partial.avatar = Some(File::use_avatar(db, &avatar, &user.id).await?);
}
// 3. Apply new status
if let Some(status) = data.status {
let mut new_status = user.status.take().unwrap_or_default();
if let Some(text) = status.text {
new_status.text = Some(text);
}
if let Some(presence) = status.presence {
new_status.presence = Some(presence);
}
partial.status = Some(new_status);
}
// 4. Apply new profile
if let Some(profile) = data.profile {
let mut new_profile = user.profile.take().unwrap_or_default();
if let Some(content) = profile.content {
new_profile.content = Some(content);
}
if let Some(background) = profile.background {
new_profile.background = Some(File::use_background(db, &background, &user.id).await?);
}
partial.profile = Some(new_profile);
}
user.update(db, partial, data.remove.unwrap_or_default())
.await?;
Ok(Json(user.foreign()))
}

View File

@@ -0,0 +1,15 @@
use revolt_quark::{
models::{Channel, User},
Database, Result,
};
use rocket::{serde::json::Json, State};
/// # Fetch Direct Message Channels
///
/// This fetches your direct messages, including any DM and group DM conversations.
#[openapi(tag = "Direct Messaging")]
#[get("/dms")]
pub async fn req(db: &State<Database>, user: User) -> Result<Json<Vec<Channel>>> {
db.find_direct_messages(&user.id).await.map(Json)
}

View File

@@ -0,0 +1,28 @@
use revolt_quark::{
models::{user::UserProfile, User},
perms, Database, Error, Ref, Result,
};
use rocket::{serde::json::Json, State};
/// # Fetch User Profile
///
/// Retrieve a user's profile data.
///
/// Will fail if you do not have permission to access the other user's profile.
#[openapi(tag = "User Information")]
#[get("/<target>/profile")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<UserProfile>> {
let target = target.as_user(db).await?;
if perms(&user)
.user(&target)
.calc_user(db)
.await
.get_view_profile()
{
Ok(Json(target.profile.unwrap_or_default()))
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,13 @@
use revolt_quark::models::User;
use revolt_quark::Result;
use rocket::serde::json::Json;
/// # Fetch Self
///
/// Retrieve your user information.
#[openapi(tag = "User Information")]
#[get("/@me")]
pub async fn req(user: User) -> Result<Json<User>> {
Ok(Json(user.foreign()))
}

View File

@@ -0,0 +1,23 @@
use revolt_quark::{models::User, perms, Database, Error, Ref, Result};
use rocket::{serde::json::Json, State};
/// # Fetch User
///
/// Retrieve a user's information.
#[openapi(tag = "User Information")]
#[get("/<target>")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<User>> {
if target.id == user.id {
return Ok(Json(user));
}
let target = target.as_user(db).await?;
let permissions = perms(&user).user(&target).calc_user(db).await;
if permissions.get_access() {
Ok(Json(target.with_perspective(&user, &permissions)))
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,46 @@
use revolt_quark::models::User;
use revolt_quark::{perms, Database, Error, Ref, Result};
use rocket::serde::json::Json;
use rocket::State;
use serde::Serialize;
/// # Mutual Friends and Servers Response
#[derive(Serialize, JsonSchema)]
pub struct MutualResponse {
/// Array of mutual user IDs that both users are friends with
users: Vec<String>,
/// Array of mutual server IDs that both users are in
servers: Vec<String>,
}
/// # Fetch Mutual Friends And Servers
///
/// Retrieve a list of mutual friends and servers with another user.
#[openapi(tag = "Relationships")]
#[get("/<target>/mutual")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<MutualResponse>> {
if target.id == user.id {
return Err(Error::InvalidOperation);
}
if user.bot.is_some() {
return Err(Error::IsBot);
}
let target = target.as_user(db).await?;
if perms(&user)
.user(&target)
.calc_user(db)
.await
.get_view_profile()
{
Ok(Json(MutualResponse {
users: db.fetch_mutual_user_ids(&user.id, &target.id).await?,
servers: db.fetch_mutual_server_ids(&user.id, &target.id).await?,
}))
} else {
Err(Error::NotFound)
}
}

View File

@@ -0,0 +1,76 @@
use rocket::http::ContentType;
use rocket::response::{self, Responder};
use rocket::{Request, Response};
use rocket_okapi::okapi::openapi3::{self, MediaType, RefOr};
use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
pub struct CachedFile((ContentType, Vec<u8>));
pub static CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
impl<'r> Responder<'r, 'static> for CachedFile {
fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
Response::build_from(self.0.respond_to(req)?)
.raw_header("Cache-Control", CACHE_CONTROL)
.ok()
}
}
impl rocket_okapi::response::OpenApiResponderInner for CachedFile {
fn responses(
_gen: &mut rocket_okapi::gen::OpenApiGenerator,
) -> std::result::Result<openapi3::Responses, rocket_okapi::OpenApiError> {
let mut responses = schemars::Map::new();
let mut content = schemars::Map::new();
content.insert(
"image/png".to_owned(),
MediaType {
schema: Some(SchemaObject {
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
format: Some("binary".to_owned()),
..Default::default()
}),
..Default::default()
},
);
responses.insert(
"200".to_string(),
RefOr::Object(openapi3::Response {
description: "Default Avatar Picture".to_string(),
content,
..Default::default()
}),
);
Ok(openapi3::Responses {
responses,
..Default::default()
})
}
}
/// # Fetch Default Avatar
///
/// This returns a default avatar based on the given id.
#[openapi(tag = "User Information")]
#[get("/<target>/default_avatar")]
pub async fn req(target: String) -> CachedFile {
CachedFile((
ContentType::PNG,
match target.chars().last().unwrap() {
// 0123456789ABCDEFGHJKMNPQRSTVWXYZ
'0' | '1' | '2' | '3' | 'S' | 'Z' => {
include_bytes!(crate::asset!("user/2.png")).to_vec()
}
'4' | '5' | '6' | '7' | 'T' => include_bytes!(crate::asset!("user/3.png")).to_vec(),
'8' | '9' | 'A' | 'B' => include_bytes!(crate::asset!("user/4.png")).to_vec(),
'C' | 'D' | 'E' | 'F' | 'V' => include_bytes!(crate::asset!("user/5.png")).to_vec(),
'G' | 'H' | 'J' | 'K' | 'W' => include_bytes!(crate::asset!("user/6.png")).to_vec(),
'M' | 'N' | 'P' | 'Q' | 'X' => include_bytes!(crate::asset!("user/7.png")).to_vec(),
/*'0' | '1' | '2' | '3' | 'R' | 'Y'*/
_ => include_bytes!(crate::asset!("user/1.png")).to_vec(),
},
))
}

View File

@@ -0,0 +1,37 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod add_friend;
mod block_user;
mod change_username;
mod edit_user;
mod fetch_dms;
mod fetch_profile;
mod fetch_self;
mod fetch_user;
mod find_mutual;
mod get_default_avatar;
mod open_dm;
mod remove_friend;
mod unblock_user;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
// User Information
fetch_self::req,
fetch_user::req,
edit_user::req,
change_username::req,
get_default_avatar::req,
fetch_profile::req,
// Direct Messaging
fetch_dms::req,
open_dm::req,
// Relationships
find_mutual::req,
add_friend::req,
remove_friend::req,
block_user::req,
unblock_user::req,
]
}

View File

@@ -0,0 +1,55 @@
use revolt_quark::{
models::{Channel, User},
perms, Database, Error, Ref, Result, UserPermission,
};
use rocket::{serde::json::Json, State};
use ulid::Ulid;
/// # Open Direct Message
///
/// Open a DM with another user.
///
/// If the target is oneself, a saved messages channel is returned.
#[openapi(tag = "Direct Messaging")]
#[get("/<target>/dm")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<Channel>> {
let target = target.as_user(db).await?;
// If the target is oneself, open saved messages.
if target.id == user.id {
return if let Ok(channel) = db.find_direct_message_channel(&user.id, &target.id).await {
Ok(Json(channel))
} else {
let new_channel = Channel::SavedMessages {
id: Ulid::new().to_string(),
user: user.id,
};
new_channel.create(db).await?;
Ok(Json(new_channel))
};
}
// Otherwise try to find or create a DM.
if let Ok(channel) = db.find_direct_message_channel(&user.id, &target.id).await {
Ok(Json(channel))
} else if perms(&user)
.user(&target)
.calc_user(db)
.await
.get_send_message()
{
let new_channel = Channel::DirectMessage {
id: Ulid::new().to_string(),
active: false,
recipients: vec![user.id, target.id],
last_message_id: None,
};
new_channel.create(db).await?;
Ok(Json(new_channel))
} else {
Error::from_user_permission(UserPermission::SendMessage)
}
}

View File

@@ -0,0 +1,21 @@
use revolt_quark::models::User;
use revolt_quark::{Database, Error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Deny Friend Request / Remove Friend
///
/// Denies another user's friend request or removes an existing friend.
#[openapi(tag = "Relationships")]
#[delete("/<target>/friend")]
pub async fn req(db: &State<Database>, user: User, target: String) -> Result<Json<User>> {
let mut target = db.fetch_user(&target).await?;
if user.bot.is_some() || target.bot.is_some() {
return Err(Error::IsBot);
}
user.remove_friend(db, &mut target).await?;
Ok(Json(target.with_auto_perspective(db, &user).await))
}

View File

@@ -0,0 +1,16 @@
use revolt_quark::models::User;
use revolt_quark::{Database, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Unblock User
///
/// Unblock another user by their id.
#[openapi(tag = "Relationships")]
#[delete("/<target>/block")]
pub async fn req(db: &State<Database>, user: User, target: String) -> Result<Json<User>> {
let mut target = db.fetch_user(&target).await?;
user.unblock_user(db, &mut target).await?;
Ok(Json(target.with_auto_perspective(db, &user).await))
}

View File

@@ -0,0 +1,103 @@
use async_std::sync::Mutex;
use mongodb::bson::doc;
use revolt_quark::{Error, Result};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket_okapi::gen::OpenApiGenerator;
use rocket_okapi::okapi::openapi3::{Parameter, ParameterValue};
use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Validate, Serialize, Deserialize)]
pub struct IdempotencyKey {
#[validate(length(min = 1, max = 64))]
key: String,
}
lazy_static! {
static ref TOKEN_CACHE: Mutex<lru::LruCache<String, ()>> = Mutex::new(lru::LruCache::new(100));
}
impl IdempotencyKey {
// Backwards compatibility.
// Issue #109
pub async fn consume_nonce(&mut self, v: Option<String>) -> Result<()> {
if let Some(v) = v {
let mut cache = TOKEN_CACHE.lock().await;
if cache.get(&v).is_some() {
return Err(Error::DuplicateNonce);
}
cache.put(v.clone(), ());
self.key = v;
}
Ok(())
}
pub fn into_key(self) -> String {
self.key
}
}
impl<'r> OpenApiFromRequest<'r> for IdempotencyKey {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> rocket_okapi::Result<RequestHeaderInput> {
Ok(RequestHeaderInput::Parameter(Parameter {
name: "Idempotency-Key".to_string(),
description: Some("Unique key to prevent duplicate requests".to_string()),
allow_empty_value: false,
required: false,
deprecated: false,
extensions: schemars::Map::new(),
location: "header".to_string(),
value: ParameterValue::Schema {
allow_reserved: false,
example: None,
examples: None,
explode: None,
style: None,
schema: SchemaObject {
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
..Default::default()
},
},
}))
}
}
#[async_trait]
impl<'r> FromRequest<'r> for IdempotencyKey {
type Error = Error;
async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
if let Some(key) = request
.headers()
.get("Idempotency-Key")
.next()
.map(|k| k.to_string())
{
let idempotency = IdempotencyKey { key };
if let Err(error) = idempotency.validate() {
return Outcome::Failure((Status::BadRequest, Error::FailedValidation { error }));
}
let mut cache = TOKEN_CACHE.lock().await;
if cache.get(&idempotency.key).is_some() {
return Outcome::Failure((Status::Conflict, Error::DuplicateNonce));
}
cache.put(idempotency.key.clone(), ());
return Outcome::Success(idempotency);
}
Outcome::Success(IdempotencyKey {
key: ulid::Ulid::new().to_string(),
})
}
}

View File

@@ -0,0 +1,3 @@
pub mod idempotency;
pub mod ratelimiter;
pub mod regex;

View File

@@ -0,0 +1,314 @@
//! Pulled from lightspeed-tv/backend.
//!
//! This will be replaced again in the near future since
//! I don't want duplication between two different projects.
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use std::ops::Add;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rocket::fairing::{Fairing, Info, Kind};
use rocket::http::uri::Origin;
use rocket::http::{Method, Status};
use rocket::request::{FromRequest, Outcome};
use rocket::serde::json::Json;
use rocket::{Data, Request, Response};
use rocket_okapi::gen::OpenApiGenerator;
use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
use serde::Serialize;
use dashmap::DashMap;
use log::info;
/// Ratelimit Bucket
#[derive(Clone, Copy)]
struct Entry {
used: u8,
reset: u128,
}
lazy_static! {
static ref MAP: DashMap<u64, Entry> = DashMap::new();
}
/// Get the current time from Unix Epoch as a Duration
fn now() -> Duration {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards...")
}
impl Entry {
/// Find bucket by its key
pub fn from(key: u64) -> Entry {
MAP.get(&key).map(|x| *x).unwrap_or_else(|| Entry {
used: 0,
reset: now().add(Duration::from_secs(10)).as_millis(),
})
}
/// Deduct one unit from the bucket and save
pub fn deduct(mut self, key: u64) {
let current_time = now().as_millis();
if current_time > self.reset {
self.used = 1;
self.reset = now().add(Duration::from_secs(10)).as_millis();
} else {
self.used += 1;
}
MAP.insert(key, self);
}
/// Get remaining units in the bucket
pub fn get_remaining(&self, limit: u8) -> u8 {
if now().as_millis() > self.reset {
limit
} else {
limit - self.used
}
}
/// Get how long bucket has until reset
pub fn left_until_reset(&self) -> u128 {
let current_time = now().as_millis();
if current_time > self.reset {
0
} else {
self.reset - current_time
}
}
}
/// Ratelimit Guard
#[derive(Serialize, Clone, Copy, Debug)]
#[allow(dead_code)]
pub struct Ratelimiter {
key: u64,
limit: u8,
remaining: u8,
reset: u128,
}
/// Find bucket from given request
///
/// Optionally, include a resource id to hash against.
fn resolve_bucket<'r>(request: &'r rocket::Request<'_>) -> (&'r str, Option<&'r str>) {
if let Some(segment) = request.routed_segment(0) {
let resource = request.routed_segment(1);
match (segment, resource) {
("users", _) => ("users", None),
("bots", _) => ("bots", None),
("channels", Some(id)) => {
if request.method() == Method::Post {
if let Some("messages") = request.routed_segment(2) {
return ("messaging", Some(id));
}
}
("channels", Some(id))
}
("servers", Some(id)) => ("servers", Some(id)),
("auth", _) => {
if request.method() == Method::Delete {
("auth_delete", None)
} else {
("auth", None)
}
}
("swagger", _) => ("swagger", None),
_ => ("any", None),
}
} else {
("any", None)
}
}
/// Resolve per-bucket limits
fn resolve_bucket_limit(bucket: &str) -> u8 {
match bucket {
"users" => 20,
"bots" => 10,
"messaging" => 10,
"channels" => 15,
"servers" => 5,
"auth" => 15,
"auth_delete" => 255,
"swagger" => 100,
_ => 20,
}
}
/// Find the remote IP of the client
fn to_ip(request: &'_ rocket::Request<'_>) -> String {
request
.remote()
.map(|x| x.ip().to_string())
.unwrap_or_default()
}
/// Find the actual IP of the client
fn to_real_ip(request: &'_ rocket::Request<'_>) -> String {
if let Ok(true) = std::env::var("TRUST_CLOUDFLARE").map(|x| x == "1") {
request
.headers()
.get_one("CF-Connecting-IP")
.map(|x| x.to_string())
.unwrap_or_else(|| to_ip(request))
} else {
to_ip(request)
}
}
impl Ratelimiter {
/// Generate guard from identifier and target bucket
pub fn from(
identifier: &str,
(bucket, resource): (&str, Option<&str>),
) -> Result<Ratelimiter, u128> {
let mut key = DefaultHasher::new();
key.write(identifier.as_bytes());
key.write(bucket.as_bytes());
if let Some(id) = resource {
key.write(id.as_bytes());
}
let key = key.finish();
let limit = resolve_bucket_limit(bucket);
let entry = Entry::from(key);
let remaining = entry.get_remaining(limit);
let reset = entry.left_until_reset();
if remaining > 0 {
entry.deduct(key);
Ok(Ratelimiter {
key,
limit,
remaining: remaining - 1,
reset,
})
} else {
Err(reset)
}
}
}
#[async_trait]
impl<'r> FromRequest<'r> for Ratelimiter {
type Error = u128;
async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
let ratelimiter = request
.local_cache_async(async {
use rocket::outcome::Outcome;
let identifier = if let Outcome::Success(session) =
request.guard::<rauth::entities::Session>().await
{
session.id.expect("`id` on User")
} else {
to_real_ip(request)
};
Ratelimiter::from(&identifier, resolve_bucket(request))
})
.await;
match ratelimiter {
Ok(ratelimiter) => Outcome::Success(*ratelimiter),
Err(retry_after) => Outcome::Failure((Status::TooManyRequests, *retry_after)),
}
}
}
impl<'r> OpenApiFromRequest<'r> for Ratelimiter {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> rocket_okapi::Result<RequestHeaderInput> {
Ok(RequestHeaderInput::None)
}
}
/// Attach ratelimiter to the Rocket application
pub struct RatelimitFairing;
#[rocket::async_trait]
impl Fairing for RatelimitFairing {
fn info(&self) -> Info {
Info {
name: "Ratelimiter",
kind: Kind::Request | Kind::Response,
}
}
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
use rocket::outcome::Outcome;
if let Outcome::Failure(_) = request.guard::<Ratelimiter>().await {
info!(
"User rate-limited on route {}! (IP = {:?})",
request.uri(),
to_real_ip(request)
);
request.set_method(Method::Get);
request.set_uri(Origin::parse("/ratelimit").unwrap())
}
}
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
use rocket::outcome::Outcome;
match request.guard::<Ratelimiter>().await {
Outcome::Success(ratelimiter) => {
let Ratelimiter {
key,
limit,
remaining,
reset,
} = ratelimiter;
response.set_raw_header("X-RateLimit-Limit", limit.to_string());
response.set_raw_header("X-RateLimit-Bucket", key.to_string());
response.set_raw_header("X-RateLimit-Remaining", remaining.to_string());
response.set_raw_header("X-RateLimit-Reset-After", reset.to_string());
}
Outcome::Failure(_) => response.set_status(Status::TooManyRequests),
Outcome::Forward(_) => unreachable!(),
}
}
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum RatelimitInformation {
Success(Ratelimiter),
Failure { retry_after: u128 },
}
#[async_trait]
impl<'r> FromRequest<'r> for RatelimitInformation {
type Error = u128;
async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
Outcome::Success(match request.guard::<Ratelimiter>().await {
Outcome::Success(ratelimiter) => RatelimitInformation::Success(ratelimiter),
Outcome::Failure((_, retry_after)) => RatelimitInformation::Failure { retry_after },
_ => unreachable!(),
})
}
}
#[get("/ratelimit")]
fn ratelimit_info(info: RatelimitInformation) -> Json<RatelimitInformation> {
Json(info)
}
pub fn routes() -> Vec<rocket::Route> {
routes![ratelimit_info]
}

View File

@@ -0,0 +1,9 @@
use once_cell::sync::Lazy;
use regex::Regex;
/// Regex for valid usernames
///
/// Block zero width space
/// Block lookalike characters
pub static RE_USERNAME: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[^\u200BА-Яа-яΑ-Ωα-ω]+$").unwrap());

View File

@@ -0,0 +1 @@
pub const VERSION: &str = "0.5.3-5-patch.2";