merge: branch 'master' into webhooks

This commit is contained in:
Paul Makles
2023-06-02 18:55:32 +01:00
80 changed files with 3511 additions and 312 deletions

View File

@@ -11,10 +11,6 @@ use rocket::serde::json::Json;
#[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)

View File

@@ -161,7 +161,12 @@ fn custom_openapi_spec() -> OpenApi {
servers: vec![
Server {
url: "https://api.revolt.chat".to_owned(),
description: Some("Revolt API".to_owned()),
description: Some("Revolt Production".to_owned()),
..Default::default()
},
Server {
url: "https://revolt.chat/api".to_owned(),
description: Some("Revolt Staging".to_owned()),
..Default::default()
},
Server {

View File

@@ -0,0 +1,34 @@
use revolt_database::{AccountStrike, Database};
use revolt_models::v0::{AccountStrike as AccountStrikeModel, DataCreateStrike};
use revolt_quark::models::User;
use revolt_quark::{Error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Create Strike
///
/// Create a new account strike
#[openapi(tag = "User Safety")]
#[post("/strikes", data = "<data>")]
pub async fn create_strike(
db: &State<Database>,
user: User,
data: Json<DataCreateStrike>,
) -> Result<Json<AccountStrikeModel>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
let data = data.into_inner();
let target = db
.fetch_user(&data.user_id)
.await
.map_err(Error::from_core)?;
AccountStrike::create(db, target.id, data.reason, user.id)
.await
.map(|strike| strike.into())
.map(Json)
.map_err(Error::from_core)
}

View File

@@ -0,0 +1,23 @@
use revolt_database::Database;
use revolt_quark::models::User;
use revolt_quark::{Error, Result};
use rocket::State;
/// # Delete Strike
///
/// Delete a strike by its ID
#[openapi(tag = "User Safety")]
#[delete("/strikes/<strike_id>")]
pub async fn delete_strike(db: &State<Database>, user: User, strike_id: String) -> Result<()> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
let strike = db
.fetch_account_strike(&strike_id)
.await
.map_err(Error::from_core)?;
strike.delete(db).await.map_err(Error::from_core)
}

View File

@@ -0,0 +1,39 @@
use revolt_database::{Database, PartialAccountStrike};
use revolt_models::v0::DataEditAccountStrike;
use revolt_quark::models::User;
use revolt_quark::{Error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Edit Strike
///
/// Edit a strike by its ID
#[openapi(tag = "User Safety")]
#[post("/strikes/<strike_id>", data = "<data>")]
pub async fn edit_strike(
db: &State<Database>,
user: User,
strike_id: String,
data: Json<DataEditAccountStrike>,
) -> Result<()> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
let mut strike = db
.fetch_account_strike(&strike_id)
.await
.map_err(Error::from_core)?;
strike
.update(
db,
PartialAccountStrike {
reason: Some(data.0.reason),
..Default::default()
},
)
.await
.map_err(Error::from_core)
}

View File

@@ -1,17 +1,61 @@
use revolt_quark::models::report::{ReportStatus, ReportStatusString, ReportedContent};
use revolt_quark::models::{Report, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
use serde::Deserialize;
/// # Query Parameters
#[derive(Deserialize, JsonSchema, FromForm)]
pub struct OptionsFetchReports {
/// Find reports against messages, servers, or users
content_id: Option<String>,
/// Find reports created by user
author_id: Option<String>,
/// Report status to include in search
status: Option<ReportStatusString>,
}
/// # Fetch Reports
///
/// Fetch all available reports
#[openapi(tag = "User Safety")]
#[get("/reports")]
pub async fn fetch_reports(db: &Db, user: User) -> Result<Json<Vec<Report>>> {
#[get("/reports?<options..>")]
pub async fn fetch_reports(
db: &Db,
user: User,
options: OptionsFetchReports,
) -> Result<Json<Vec<Report>>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
db.fetch_reports().await.map(Json)
let mut reports = db.fetch_reports().await?;
if let Some(content_id) = options.content_id {
reports.retain(|report| match &report.content {
ReportedContent::Message { id, .. }
| ReportedContent::Server { id, .. }
| ReportedContent::User { id, .. } => id == &content_id,
});
}
if let Some(author_id) = options.author_id {
reports.retain(|report| report.author_id == author_id);
}
if let Some(status) = options.status {
reports.retain(|report| {
matches!(
(&status, &report.status),
(ReportStatusString::Created, ReportStatus::Created { .. })
| (ReportStatusString::Rejected, ReportStatus::Rejected { .. })
| (ReportStatusString::Resolved, ReportStatus::Resolved { .. })
)
});
}
Ok(Json(reports))
}

View File

@@ -1,82 +0,0 @@
use std::collections::HashSet;
use revolt_quark::models::snapshot::{SnapshotContent, SnapshotWithContext};
use revolt_quark::models::{Channel, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
/// # Fetch Snapshot
///
/// Fetch a snapshot for a given report
#[openapi(tag = "User Safety")]
#[get("/snapshot/<report_id>")]
pub async fn fetch_snapshot(
db: &Db,
user: User,
report_id: String,
) -> Result<Json<SnapshotWithContext>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Fetch snapshot
let snapshot = db.fetch_snapshot(&report_id).await?;
// Resolve and fetch IDs of associated content
let mut user_ids: HashSet<&str> = HashSet::new();
let mut channel_ids: HashSet<&str> = HashSet::new();
match &snapshot.content {
SnapshotContent::Message {
prior_context,
leading_context,
message,
} => {
for msg in prior_context {
user_ids.insert(&msg.author);
}
for msg in leading_context {
user_ids.insert(&msg.author);
}
user_ids.insert(&message.author);
channel_ids.insert(&message.channel);
}
SnapshotContent::User(user) => {
user_ids.insert(&user.id);
}
SnapshotContent::Server(server) => {
for channel in &server.channels {
channel_ids.insert(channel);
}
}
}
// Collect user and channel IDs
let user_ids: Vec<String> = user_ids.into_iter().map(|s| s.to_owned()).collect();
let channel_ids: Vec<String> = channel_ids.into_iter().map(|s| s.to_owned()).collect();
// Fetch users and channels
let users = db.fetch_users(&user_ids).await?;
let channels = db.fetch_channels(&channel_ids).await?;
// Pull out first server from channels if possible
let server = if let Some(server_id) = channels.iter().find_map(|channel| match channel {
Channel::TextChannel { server, .. } => Some(server),
_ => None,
}) {
Some(db.fetch_server(server_id).await?)
} else {
None
};
// Return snapshot with context
Ok(Json(SnapshotWithContext {
snapshot,
users,
channels,
server,
}))
}

View File

@@ -0,0 +1,87 @@
use std::collections::HashSet;
use revolt_quark::models::snapshot::{SnapshotContent, SnapshotWithContext};
use revolt_quark::models::{Channel, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
/// # Fetch Snapshots
///
/// Fetch a snapshots for a given report
#[openapi(tag = "User Safety")]
#[get("/snapshot/<report_id>")]
pub async fn fetch_snapshots(
db: &Db,
user: User,
report_id: String,
) -> Result<Json<Vec<SnapshotWithContext>>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Fetch snapshots
let snapshots = db.fetch_snapshots(&report_id).await?;
let mut result = vec![];
for snapshot in snapshots {
// Resolve and fetch IDs of associated content
let mut user_ids: HashSet<&str> = HashSet::new();
let mut channel_ids: HashSet<&str> = HashSet::new();
match &snapshot.content {
SnapshotContent::Message {
prior_context,
leading_context,
message,
} => {
for msg in prior_context {
user_ids.insert(&msg.author);
}
for msg in leading_context {
user_ids.insert(&msg.author);
}
user_ids.insert(&message.author);
channel_ids.insert(&message.channel);
}
SnapshotContent::User(user) => {
user_ids.insert(&user.id);
}
SnapshotContent::Server(server) => {
for channel in &server.channels {
channel_ids.insert(channel);
}
}
}
// Collect user and channel IDs
let user_ids: Vec<String> = user_ids.into_iter().map(|s| s.to_owned()).collect();
let channel_ids: Vec<String> = channel_ids.into_iter().map(|s| s.to_owned()).collect();
// Fetch users and channels
let users = db.fetch_users(&user_ids).await?;
let channels = db.fetch_channels(&channel_ids).await?;
// Pull out first server from channels if possible
let server = if let Some(server_id) = channels.iter().find_map(|channel| match channel {
Channel::TextChannel { server, .. } => Some(server),
_ => None,
}) {
Some(db.fetch_server(server_id).await?)
} else {
None
};
// Return snapshot with context
result.push(SnapshotWithContext {
snapshot,
users,
channels,
server,
});
}
Ok(Json(result))
}

View File

@@ -0,0 +1,28 @@
use revolt_database::Database;
use revolt_models::v0::AccountStrike;
use revolt_quark::models::User;
use revolt_quark::{Error, Result};
use rocket::serde::json::Json;
use rocket::State;
/// # Fetch Strikes
///
/// Fetch strikes for a user by their ID
#[openapi(tag = "User Safety")]
#[get("/strikes/<user_id>")]
pub async fn fetch_strikes(
db: &State<Database>,
user: User,
user_id: String,
) -> Result<Json<Vec<AccountStrike>>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
db.fetch_account_strikes_by_user(&user_id)
.await
.map(|v| v.into_iter().map(|e| e.into()).collect())
.map(Json)
.map_err(Error::from_core)
}

View File

@@ -6,7 +6,12 @@ mod fetch_report;
mod fetch_reports;
mod report_content;
mod fetch_snapshot;
mod fetch_snapshots;
mod create_strike;
mod delete_strike;
mod edit_strike;
mod fetch_strikes;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
@@ -16,6 +21,11 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
fetch_reports::fetch_reports,
report_content::report_content,
// Snapshots
fetch_snapshot::fetch_snapshot
fetch_snapshots::fetch_snapshots,
// Strikes
create_strike::create_strike,
fetch_strikes::fetch_strikes,
edit_strike::edit_strike,
delete_strike::delete_strike
]
}

View File

@@ -1,5 +1,4 @@
use revolt_quark::events::client::EventV1;
use revolt_quark::models::message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod};
use revolt_quark::models::report::{ReportStatus, ReportedContent};
use revolt_quark::models::snapshot::{Snapshot, SnapshotContent};
use revolt_quark::models::{Report, User};
@@ -38,7 +37,7 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
// Find the content and create a snapshot of it
// Also retrieve any references to Files
let (content, files): (SnapshotContent, Vec<String>) = match &data.content {
let (snapshots, files): (Vec<SnapshotContent>, Vec<String>) = match &data.content {
ReportedContent::Message { id, .. } => {
let message = db.fetch_message(id).await?;
@@ -47,53 +46,8 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
return Err(Error::CannotReportYourself);
}
// Collect message attachments
let files = message
.attachments
.as_ref()
.map(|attachments| attachments.iter().map(|x| x.id.to_string()).collect())
.unwrap_or_default();
// Collect prior context
let prior_context = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: Some(message.id.to_string()),
after: None,
sort: Some(MessageSort::Latest),
},
})
.await?;
// Collect leading context
let leading_context = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: None,
after: Some(message.id.to_string()),
sort: Some(MessageSort::Oldest),
},
})
.await?;
(
SnapshotContent::Message {
message,
prior_context,
leading_context,
},
files,
)
let (snapshot, files) = SnapshotContent::generate_from_message(db, message).await?;
(vec![snapshot], files)
}
ReportedContent::Server { id, .. } => {
let server = db.fetch_server(id).await?;
@@ -103,15 +57,10 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
return Err(Error::CannotReportYourself);
}
// Collect server's icon and banner
let files = [&server.icon, &server.banner]
.iter()
.filter_map(|x| x.as_ref().map(|x| x.id.to_string()))
.collect();
(SnapshotContent::Server(server), files)
let (snapshot, files) = SnapshotContent::generate_from_server(server)?;
(vec![snapshot], files)
}
ReportedContent::User { id, .. } => {
ReportedContent::User { id, message_id, .. } => {
let reported_user = db.fetch_user(id).await?;
// Users cannot report themselves
@@ -119,19 +68,25 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
return Err(Error::CannotReportYourself);
}
// Collect user's avatar and profile background
let files = [
reported_user.avatar.as_ref(),
reported_user
.profile
.as_ref()
.and_then(|profile| profile.background.as_ref()),
]
.iter()
.filter_map(|x| x.as_ref().map(|x| x.id.to_string()))
.collect();
// Determine if there is a message provided as context
let message = if let Some(id) = message_id {
db.fetch_message(id).await.ok()
} else {
None
};
(SnapshotContent::User(reported_user), files)
let (snapshot, files) = SnapshotContent::generate_from_user(reported_user)?;
if let Some(message) = message {
let (message_snapshot, message_files) =
SnapshotContent::generate_from_message(db, message).await?;
(
vec![snapshot, message_snapshot],
[files, message_files].concat(),
)
} else {
(vec![snapshot], files)
}
}
};
@@ -143,14 +98,17 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
// Generate an id for the report
let id = Ulid::new().to_string();
// Save a snapshot of the content
let snapshot = Snapshot {
id: Ulid::new().to_string(),
report_id: id.to_string(),
content,
};
// Insert all new generated snapshots
for content in snapshots {
// Save a snapshot of the content
let snapshot = Snapshot {
id: Ulid::new().to_string(),
report_id: id.to_string(),
content,
};
db.insert_snapshot(&snapshot).await?;
db.insert_snapshot(&snapshot).await?;
}
// Save the report
let report = Report {

View File

@@ -0,0 +1,82 @@
use revolt_quark::{
models::{Member, User},
perms, Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Query Parameters
#[derive(Deserialize, JsonSchema, FromForm)]
pub struct OptionsQueryMembers {
/// String to search for
query: String,
/// Discourage use of this API
experimental_api: bool,
}
/// # Query members by name
#[derive(Serialize, JsonSchema)]
pub struct MemberQueryResponse {
/// List of members
members: Vec<Member>,
/// List of users
users: Vec<User>,
}
/// # Query members by name
///
/// Query members by a given name, this API is not stable and will be removed in the future.
#[openapi(tag = "Server Members")]
#[get("/<target>/members_experimental_query?<options..>")]
pub async fn member_experimental_query(
db: &Db,
user: User,
target: Ref,
options: OptionsQueryMembers,
) -> Result<Json<MemberQueryResponse>> {
if !options.experimental_api {
return Err(Error::InternalError);
}
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 mut users = User::fetch_foreign_users(db, &user_ids).await?;
// 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));
// Filter all matches
let mut zipped_vec: Vec<(Member, User)> = members
.into_iter()
.zip(users)
.filter(|(member, user)| {
if let Some(nickname) = &member.nickname {
nickname.contains(&options.query)
} else {
user.username.contains(&options.query)
}
})
.collect();
// Sort remaining matches by length
zipped_vec.sort_by(|(member_a, user_a), (member_b, user_b)| {
let left = member_a.nickname.as_ref().unwrap_or(&user_a.username);
let right = member_b.nickname.as_ref().unwrap_or(&user_b.username);
left.len().cmp(&right.len())
});
// Take the first five and return them
let (members, users) = zipped_vec.into_iter().take(10).unzip();
Ok(Json(MemberQueryResponse { members, users }))
}

View File

@@ -8,6 +8,7 @@ mod channel_create;
mod emoji_list;
mod invites_fetch;
mod member_edit;
mod member_experimental_query;
mod member_fetch;
mod member_fetch_all;
mod member_remove;
@@ -34,6 +35,7 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
member_remove::req,
member_fetch::req,
member_edit::req,
member_experimental_query::member_experimental_query,
ban_create::req,
ban_remove::req,
ban_list::req,

View File

@@ -78,7 +78,9 @@ pub async fn req(
&& data.system_messages.is_none()
&& data.categories.is_none()
// && data.nsfw.is_none()
&& data.flags.is_none()
&& data.analytics.is_none()
&& data.discoverable.is_none()
&& data.remove.is_none()
{
return Ok(Json(server));
@@ -87,7 +89,6 @@ pub async fn req(
|| 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()
{
@@ -97,10 +98,13 @@ pub async fn req(
}
// Check we are privileged if changing sensitive fields
if data.flags.is_some() && !user.privileged {
if (data.flags.is_some() /*|| data.nsfw.is_some()*/ || data.discoverable.is_some())
&& !user.privileged
{
return Err(Error::NotPrivileged);
}
// Changing categories requires manage channel
if data.categories.is_some() {
permissions
.throw_permission(db, Permission::ManageChannel)

View File

@@ -1,17 +1,65 @@
use revolt_quark::{
models::{Server, User},
models::{Channel, Server, User},
perms, Db, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Query Parameters
#[derive(Deserialize, JsonSchema, FromForm)]
pub struct OptionsFetchServer {
/// Whether to include channels
include_channels: Option<bool>,
}
/// # Fetch server route response
#[derive(Serialize, JsonSchema)]
#[serde(untagged)]
pub enum FetchServerResponse {
JustServer(Server),
ServerWithChannels {
#[serde(flatten)]
server: Server,
channels: Vec<Channel>,
},
}
/// # 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>> {
#[get("/<target>?<options..>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: OptionsFetchServer,
) -> Result<Json<FetchServerResponse>> {
let server = target.as_server(db).await?;
perms(&user).server(&server).calc(db).await?;
let mut perms = perms(&user).server(&server);
perms.calc(db).await?;
Ok(Json(server))
if let Some(true) = options.include_channels {
let all_channels = db.fetch_channels(&server.channels).await?;
let mut visible_channels = vec![];
for channel in all_channels {
if perms
.clone()
.channel(&channel)
.calc(db)
.await?
.can_view_channel()
{
visible_channels.push(channel);
}
}
Ok(Json(FetchServerResponse::ServerWithChannels {
server,
channels: visible_channels,
}))
} else {
Ok(Json(FetchServerResponse::JustServer(server)))
}
}

View File

@@ -66,14 +66,20 @@ pub async fn req(
// If we want to edit a different user than self, ensure we have
// permissions and subsequently replace the user in question
if target.id != "@me" {
if !user.privileged {
if target.id != "@me" && target.id != user.id {
let target_user = target.as_user(db).await?;
let is_bot_owner = target_user
.bot
.map(|bot| bot.owner == user.id)
.unwrap_or_default();
if !is_bot_owner && !user.privileged {
return Err(Error::NotPrivileged);
}
}
user = target.as_user(db).await?;
// Otherwise, filter out invalid edit fields
} else if data.badges.is_some() || data.flags.is_some() {
if !user.privileged && (data.badges.is_some() || data.flags.is_some()) {
return Err(Error::NotPrivileged);
}