1
0
mirror of https://git.ngram.ca/OpenJam/rc-servers synced 2026-08-23 23:08:52 +00:00

Add minimum chat moderation to enable bans for #22

This commit is contained in:
NG (Graham)
2025-05-29 20:55:36 -04:00
parent 9d9f8a73fc
commit c441b8b5ef
15 changed files with 467 additions and 7 deletions

View File

@@ -0,0 +1,47 @@
use polariton_server::operations::{SimpleOpError, SimpleOperation, SimpleOpImpl};
use polariton::operation::{ParameterTable, Typed};
const CODE: u8 = 7;
const SANCTION_TY_PARAM_KEY: u8 = 9; // int; in
const IS_ADDING_PARAM_KEY: u8 = 10; // bool; in
const DURATION_PARAM_KEY: u8 = 11; // int; in
const REASON_PARAM_KEY: u8 = 2; // str; in
const USERNAME_PARAM_KEY: u8 = 7; // str; in
pub(super) struct AddSanctionProvider;
#[async_trait::async_trait]
impl <C: Send + 'static> SimpleOperation<C> for AddSanctionProvider {
type User = crate::UserTy;
const CODE: u8 = CODE;
async fn handle(&self, params: ParameterTable<C>, user: &Self::User) -> Result<ParameterTable<C>, SimpleOpError> {
let mut params = params.to_dict();
if let Some(Typed::Int(sanction_ty)) = params.remove(&SANCTION_TY_PARAM_KEY) {
if let Some(Typed::Bool(is_adding)) = params.remove(&IS_ADDING_PARAM_KEY) {
if let Some(Typed::Int(duration)) = params.remove(&DURATION_PARAM_KEY) {
if let Some(Typed::Str(reason)) = params.remove(&REASON_PARAM_KEY) {
if let Some(Typed::Str(username)) = params.remove(&USERNAME_PARAM_KEY) {
let user_info = user.user()?;
let sanction = rc_core::persist::user::SetSanction {
type_: rc_core::persist::user::SanctionType::from_i32(sanction_ty)?,
is_adding,
duration,
reason: reason.string,
username: username.string,
};
user_info.set_sanction(sanction).await?;
return Ok(params.into());
}
}
}
}
}
Err((rc_core::data::error_codes::ChatErrorCodes::UnexpectedError as i16).into())
}
}
pub(super) fn add_modify_sanction_provider<C: Send + 'static>() -> SimpleOpImpl<C, crate::UserTy, AddSanctionProvider> {
SimpleOpImpl::new(AddSanctionProvider)
}

View File

@@ -0,0 +1,30 @@
use polariton_server::operations::{SimpleOpError, SimpleOperation, SimpleOpImpl};
use polariton::operation::{ParameterTable, Typed};
const CODE: u8 = 16;
const SANCTIONS_PARAM_KEY: u8 = 31; // arr of str; out
const USERNAME_PARAM_KEY: u8 = 22; // str; in
pub(super) struct GetSanctionsProvider;
#[async_trait::async_trait]
impl SimpleOperation<()> for GetSanctionsProvider {
type User = crate::UserTy;
const CODE: u8 = CODE;
async fn handle(&self, params: ParameterTable, user: &Self::User) -> Result<ParameterTable, SimpleOpError> {
let mut params = params.to_dict();
if let Some(Typed::Str(username)) = params.remove(&USERNAME_PARAM_KEY) {
let user_info = user.user()?;
let sanctions = user_info.get_sanctions(username.string).await?;
params.insert(SANCTIONS_PARAM_KEY, sanctions);
return Ok(params.into());
}
Err((rc_core::data::error_codes::ChatErrorCodes::UnexpectedError as i16).into())
}
}
pub(super) fn list_sanctions_provider() -> SimpleOpImpl<(), crate::UserTy, GetSanctionsProvider> {
SimpleOpImpl::new(GetSanctionsProvider)
}

View File

@@ -7,6 +7,8 @@ mod public_channels;
mod join_channel;
mod user_online;
mod subscribed_channels;
mod add_modify_sanction;
mod list_sanctions;
use polariton_server::operations::OperationsHandler;
@@ -25,5 +27,7 @@ pub fn handler(chat_system: crate::state::chat::ChatImpl, conf: &rc_core::persis
.add(send_message::send_private_message_handler(chat_system.clone()))
.add(join_channel::leave_channel_provider(chat_system.clone()))
.add(subscribed_channels::all_subbed_channels_provider())
.add(add_modify_sanction::add_modify_sanction_provider())
.add(list_sanctions::list_sanctions_provider())
//.add(polariton_server::operations::Ack::<00000, _>::default())
}

View File

@@ -1,12 +1,26 @@
use polariton_server::operations::SimpleFunc;
use polariton_server::operations::{SimpleOpError, SimpleOperation, SimpleOpImpl};
use polariton::operation::{ParameterTable, Typed};
const CODE: u8 = 15;
const PARAM_KEY: u8 = 28;
pub(super) fn pending_sanctions_checker() -> SimpleFunc<15, crate::UserTy, impl (Fn(ParameterTable, &crate::UserTy) -> Result<ParameterTable, i16>) + Sync + Sync> {
SimpleFunc::new(|params, _| {
pub(super) struct PendingSanctionsProvider;
#[async_trait::async_trait]
impl <C: Send + 'static> SimpleOperation<C> for PendingSanctionsProvider {
type User = crate::UserTy;
const CODE: u8 = CODE;
async fn handle(&self, params: ParameterTable<C>, _user: &Self::User) -> Result<ParameterTable<C>, SimpleOpError> {
let mut params = params.to_dict();
params.insert(PARAM_KEY, Typed::Bool(false.into()));
//let user_info = user.user()?;
//params.insert(PARAM_KEY, Typed::Bool(user_info.has_pending_sanctions().await?));
params.insert(PARAM_KEY, Typed::Bool(false));
Ok(params.into())
})
}
}
pub(super) fn pending_sanctions_checker<C: Send + 'static>() -> SimpleOpImpl<C, crate::UserTy, PendingSanctionsProvider> {
SimpleOpImpl::new(PendingSanctionsProvider)
}

View File

@@ -12,6 +12,7 @@ pub mod weapon_list;
pub mod weapon_upgrade;
pub mod crf;
pub mod channel;
pub mod sanction;
pub mod error_codes;

View File

@@ -0,0 +1,72 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SanctionJson {
#[serde(rename="Type")]
pub type_: SanctionType,
#[serde(rename="Reason")]
pub reason: String,
#[serde(rename="Reporter")]
pub reporter: String,
#[serde(rename="Issued", serialize_with="serde_issued::serialize")]
pub issued: chrono::DateTime<chrono::Utc>,
}
impl SanctionJson {
pub fn as_json(&self) -> String {
serde_json::to_string(&self).unwrap()
}
}
mod serde_issued {
use chrono::{DateTime, Utc};
/*pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
d.deserialize_str(todo!())
}*/
pub fn serialize<S: serde::Serializer>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&dt.to_rfc2822())
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug)]
pub enum SanctionType {
Warning = 0,
Mute = 1,
Suspension = 2,
Note = 3,
Kick = 4,
}
impl SanctionType {
pub(crate) fn from_db(t: rc_database::schema::sanction::Descriptor) -> Self {
match t {
rc_database::schema::sanction::Descriptor::Warn => Self::Warning,
rc_database::schema::sanction::Descriptor::Mute => Self::Mute,
rc_database::schema::sanction::Descriptor::Ban => Self::Suspension,
rc_database::schema::sanction::Descriptor::Note => Self::Note,
rc_database::schema::sanction::Descriptor::Kick => Self::Kick,
}
}
pub(crate) fn to_db(self) -> rc_database::schema::sanction::Descriptor {
match self {
Self::Warning => rc_database::schema::sanction::Descriptor::Warn,
Self::Mute => rc_database::schema::sanction::Descriptor::Mute,
Self::Suspension => rc_database::schema::sanction::Descriptor::Ban,
Self::Note => rc_database::schema::sanction::Descriptor::Note,
Self::Kick => rc_database::schema::sanction::Descriptor::Kick,
}
}
pub(crate) fn from_persist(t: crate::persist::user::SanctionType) -> Self {
match t {
crate::persist::user::SanctionType::Warn => Self::Warning,
crate::persist::user::SanctionType::Mute => Self::Mute,
crate::persist::user::SanctionType::Ban => Self::Suspension,
crate::persist::user::SanctionType::Note => Self::Note,
crate::persist::user::SanctionType::Kick => Self::Kick,
}
}
}

View File

@@ -198,6 +198,33 @@ impl UserData {
Ok(())
}
}
fn check_perms_to_exec(&self, ty: &super::SanctionType) -> Result<(), i16> {
match ty {
super::SanctionType::Warn
| super::SanctionType::Mute
| super::SanctionType::Note => if self.has_any_elevated_perms() {
Ok(())
} else {
Err(crate::data::error_codes::ChatErrorCodes::ModeratorsOnly as i16)
},
super::SanctionType::Ban
| super::SanctionType::Kick => if self.has_admin_or_better_perms() {
Ok(())
} else {
Err(crate::data::error_codes::ChatErrorCodes::AdminsOnly as i16)
},
}
}
#[inline]
fn has_any_elevated_perms(&self) -> bool {
self.perms.moderator | self.perms.administrator | self.perms.developer
}
fn has_admin_or_better_perms(&self) -> bool {
self.perms.administrator | self.perms.developer
}
}
const INVALID_ROBOT_ERR: i16 = crate::data::error_codes::WebServicesError::InvalidRobot as i16; // 140
@@ -716,4 +743,83 @@ impl super::ChatUser for UserData {
}
Ok(())
}
/*async fn has_pending_sanctions(&self) -> Result<bool, i16> {
let count = self.db.count_sanctions_to_ack_by_user_id_and_descriptor(self.account.id, rc_database::schema::sanction::Descriptor::Warn).await.map_err(|e| {
log::error!("Failed to count pending sanctions for user_id {}: {}", self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
Ok(count != 0)
}*/
async fn get_sanctions(&self, username: String) -> Result<polariton::operation::Typed<()>, i16> {
let user_opt = self.db.user_by_display_name(username.clone()).await.map_err(|e| {
log::error!("Failed to retrieve user by username {} for user_id {}: {}", username, self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
if let Some(user) = user_opt {
let sanctions = self.db.sanctions_by_user_id(user.id).await.map_err(|e| {
log::error!("Failed to retrieve sanctions by username {} for user_id {}: {}", username, self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
Ok(polariton::operation::Typed::Arr(polariton::operation::Arr {
ty: polariton::serdes::TypePrefix::Str,
items: sanctions.into_iter().map(|x| {
let data = crate::data::sanction::SanctionJson {
type_: crate::data::sanction::SanctionType::from_db(x.descriptor),
reason: x.reason,
reporter: x.issuer_name,
issued: chrono::DateTime::from_timestamp(x.creation_time, 0).unwrap(),
};
polariton::operation::Typed::Str(data.as_json().into())
}).collect(),
}))
} else {
Err(crate::data::error_codes::ChatErrorCodes::DoesNotExist as i16)
}
}
async fn set_sanction(&self, sanction: super::SetSanction) -> Result<(), i16> {
self.check_perms_to_exec(&sanction.type_)?;
let user_opt = self.db.user_by_display_name(sanction.username.clone()).await.map_err(|e| {
log::error!("Failed to retrieve user by username {} for user_id {}: {}", sanction.username, self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
if let Some(user) = user_opt {
if sanction.is_adding {
let now = chrono::Utc::now().timestamp();
let sanction_ty = crate::data::sanction::SanctionType::from_persist(sanction.type_).to_db();
let to_add = rc_database::schema::sanction::ActiveModel {
user_id: rc_database::sea_orm::ActiveValue::Set(user.id),
creation_time: rc_database::sea_orm::ActiveValue::Set(now),
issuer_id: rc_database::sea_orm::ActiveValue::Set(self.account.id),
issuer_name: rc_database::sea_orm::ActiveValue::Set(self.account.display_name.clone()),
descriptor: rc_database::sea_orm::ActiveValue::Set(sanction_ty.clone()),
reason: rc_database::sea_orm::ActiveValue::Set(sanction.reason),
duration: rc_database::sea_orm::ActiveValue::Set(if sanction.duration <= 0 { None } else { Some(sanction.duration as i64) }),
..Default::default()
};
if matches!(sanction_ty, rc_database::schema::sanction::Descriptor::Ban) {
self.db.update_perms_by_user_id(rc_database::schema::permissions::ActiveModel {
banned: rc_database::sea_orm::ActiveValue::Set(true),
..Default::default()
}, user.id).await.map_err(|e| {
log::error!("Failed to update permissions (to ban) for user_id {} by user_id {}: {}", user.id, self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
}
self.db.insert_sanction(to_add).await.map_err(|e| {
log::error!("Failed to insert sanction for user_id {} by user_id {}: {}", user.id, self.account.id, e);
crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16
})?;
Ok(())
} else {
// FIXME
log::error!("Modifying sanctions is not currently supported");
Err(crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16)
}
} else {
Err(crate::data::error_codes::ChatErrorCodes::DoesNotExist as i16)
}
}
}

View File

@@ -11,7 +11,7 @@ mod inventory;
pub use inventory::UnlockedParts;
mod traits;
pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData};
pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType};
pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key";

View File

@@ -190,5 +190,36 @@ pub trait ChatUser {
async fn subscribed_channels_strings(&self) -> Result<Vec<String>, i16>;
async fn add_subscribed_channel(&self, channel: String, channel_ty: crate::data::channel::ChatChannelType) -> Result<polariton::operation::Typed<()>, i16>;
async fn remove_subscribed_channel(&self, channel: String, channel_ty: crate::data::channel::ChatChannelType) -> Result<(), i16>;
//async fn has_pending_sanctions(&self) -> Result<bool, i16>;
async fn get_sanctions(&self, username: String) -> Result<polariton::operation::Typed<()>, i16>;
async fn set_sanction(&self, sanction: SetSanction) -> Result<(), i16>;
}
pub struct SetSanction {
pub is_adding: bool, // if false, it's modifying
pub type_: SanctionType,
pub duration: i32,
pub reason: String,
pub username: String,
}
pub enum SanctionType {
Warn = 0,
Mute = 1,
Ban = 2,
Note = 3,
Kick = 4,
}
impl SanctionType {
pub fn from_i32(i: i32) -> Result<Self, i16> {
match i {
0 => Ok(Self::Warn),
1 => Ok(Self::Mute),
2 => Ok(Self::Ban),
3 => Ok(Self::Note),
4 => Ok(Self::Kick),
_ => Err(crate::data::error_codes::ChatErrorCodes::UnexpectedError as i16),
}
}
}

View File

@@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20250529_000001_create_sanction_table"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
// Define how to apply this migration: Create the Sanctions table.
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(crate::schema::sanction::Entity)
.col(
ColumnDef::new(crate::schema::sanction::Column::Id)
.unsigned()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(crate::schema::sanction::Column::UserId).unsigned().not_null())
.foreign_key(
ForeignKey::create()
.name("fk-sanction-user_id")
.from(crate::schema::sanction::Entity, crate::schema::sanction::Column::UserId)
.to(crate::schema::user::Entity, crate::schema::user::Column::Id),
)
.col(ColumnDef::new(crate::schema::sanction::Column::CreationTime).big_integer().not_null())
.col(ColumnDef::new(crate::schema::sanction::Column::IssuerId).unsigned().not_null())
.foreign_key(
ForeignKey::create()
.name("fk-sanction-issuer_id")
.from(crate::schema::sanction::Entity, crate::schema::sanction::Column::IssuerId)
.to(crate::schema::user::Entity, crate::schema::user::Column::Id),
)
.col(ColumnDef::new(crate::schema::sanction::Column::IssuerName).string().not_null())
.col(ColumnDef::new(crate::schema::sanction::Column::Descriptor).string().not_null())
.col(ColumnDef::new(crate::schema::sanction::Column::Reason).string().not_null())
.col(ColumnDef::new(crate::schema::sanction::Column::Duration).big_integer())
.col(ColumnDef::new(crate::schema::sanction::Column::Acknowledged).big_integer())
.col(ColumnDef::new(crate::schema::sanction::Column::AppealerId).unsigned())
.foreign_key(
ForeignKey::create()
.name("fk-sanction-appealer_id")
.from(crate::schema::sanction::Entity, crate::schema::sanction::Column::AppealerId)
.to(crate::schema::user::Entity, crate::schema::user::Column::Id),
)
.col(ColumnDef::new(crate::schema::sanction::Column::AppealTime).big_integer())
.to_owned(),
)
.await
}
// Define how to rollback this migration: Drop the Sanctions table.
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(crate::schema::sanction::Entity).to_owned())
.await
}
}

View File

@@ -6,6 +6,7 @@ mod m20250424_000003_create_garage_table;
mod m20250424_000004_create_user_aux_table;
mod m20250424_000005_create_campaign_tables;
mod m20250526_000001_add_garage_customisation;
mod m20250529_000001_create_sanction_table;
pub struct Migrator;
@@ -19,6 +20,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250424_000004_create_user_aux_table::Migration),
Box::new(m20250424_000005_create_campaign_tables::Migration),
Box::new(m20250526_000001_add_garage_customisation::Migration),
Box::new(m20250529_000001_create_sanction_table::Migration),
]
}
}

View File

@@ -5,6 +5,7 @@ pub mod garage;
pub mod campaign;
pub mod campaign_difficulty_completion;
pub mod common_query;
pub mod sanction;
pub fn parse_int_csv(s: &str) -> Vec<u32> {
s.split(',').filter_map(|i_as_s| {

View File

@@ -0,0 +1,46 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "sanctions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: u32,
pub user_id: u32,
pub creation_time: i64, // seconds since unix epoch
pub issuer_id: u32,
pub issuer_name: String,
pub descriptor: Descriptor,
pub reason: String,
pub duration: Option<i64>, // seconds after creation_time (null means permanent or irrelevant)
pub acknowledged: Option<i64>, // seconds since unix epoch
pub appealer_id: Option<u32>, // moderator who approved appeal
pub appeal_time: Option<i64>, // seconds since unix epoch
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)", rename_all = "PascalCase")]
pub enum Descriptor {
Warn,
Mute,
Ban,
Note,
Kick,
}

View File

@@ -1,5 +1,5 @@
use sea_orm_migration::MigratorTrait;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait};
pub struct Database {
orm: sea_orm::DatabaseConnection,
@@ -105,6 +105,24 @@ impl Database {
entity.insert(&self.orm).await
}
pub async fn update_perms_by_user_id(&self, mut entity: crate::schema::permissions::ActiveModel, user_id: u32) -> Result<Option<crate::schema::permissions::Model>, sea_orm::DbErr> {
let id_opt = crate::schema::permissions::Entity::find()
.select_only()
.column(crate::schema::permissions::Column::Id)
.filter(crate::schema::permissions::Column::UserId.eq(user_id))
.into_model::<crate::schema::common_query::Id>()
.one(&self.orm)
.await?;
if let Some(id) = id_opt {
entity.id = sea_orm::ActiveValue::Set(id.id);
Ok(Some(crate::schema::permissions::Entity::update(entity)
.exec(&self.orm)
.await?))
} else {
Ok(None)
}
}
pub async fn garage_max_slot_by_user_id(&self, user_id: u32) -> Result<u32, sea_orm::DbErr> {
let result = crate::schema::garage::Entity::find()
.select_only()
@@ -227,4 +245,26 @@ impl Database {
})?;
Ok(())
}
pub async fn count_sanctions_to_ack_by_user_id_and_descriptor(&self, user_id: u32, desc: crate::schema::sanction::Descriptor) -> Result<u64, sea_orm::DbErr> {
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
.filter(crate::schema::sanction::Column::Descriptor.eq(desc))
.filter(crate::schema::sanction::Column::Acknowledged.is_null())
.count(&self.orm)
.await
}
pub async fn sanctions_by_user_id(&self, user_id: u32) -> Result<Vec<crate::schema::sanction::Model>, sea_orm::DbErr> {
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
.filter(crate::schema::sanction::Column::Acknowledged.is_null())
.order_by_asc(crate::schema::sanction::Column::CreationTime)
.all(&self.orm)
.await
}
pub async fn insert_sanction(&self, entity: crate::schema::sanction::ActiveModel) -> Result<crate::schema::sanction::Model, sea_orm::DbErr> {
entity.insert(&self.orm).await
}
}

View File

@@ -164,6 +164,7 @@ pub fn handler(init_ctx: &crate::InitConfig) -> OperationsHandler<crate::UserTy>
.add(polariton_server::operations::Ack::<152, _>::default()) // custom game player state changed (188 is desired state)
.add(custom_games_invite::pending_invite_provider())
.add(chat_settings::chat_settings_provider())
.add(polariton_server::operations::Ack::<19, _>::default()) // save chat settings
.add(prebuilt_robots::garage_robot_data_provider())
.add(prebuilt_colours::garage_colour_combo_provider())
.add(robopass_preview_items::robopass_preview_provider())