feat(api): add moderation events and visibility actions (#3219)

This commit is contained in:
Hampus
2026-10-05 13:24:19 +02:00
committed by GitHub
parent 532e828fe6
commit 05257d6439
39 changed files with 2464 additions and 88 deletions
+234 -10
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse};
use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse, now_ms};
#[cfg(feature = "scylla")]
use chrono::{DateTime, NaiveDate, Utc};
use fluxer_common::user_flags::AccountStanding;
use fluxer_svc::shard::ShardService;
use fluxer_svc::{postgres, postgres::KeyPart};
use futures::stream::{self, StreamExt};
@@ -54,13 +55,14 @@ const FULL_USER_COLUMNS: &str = "\
first_refund_at, version, \
premium_grace_ends_at, mention_flags, \
last_voice_activity_sharing_change_at, \
timezone, timezone_privacy_flags";
timezone, timezone_privacy_flags, content_hidden_since";
#[cfg(feature = "scylla")]
const PARTIAL_USER_COLUMNS: &str = "\
user_id, username, discriminator, global_name, \
avatar_hash, bot, system, flags, \
banner_hash, banner_color, accent_color, avatar_color, \
mention_flags";
mention_flags, temp_banned_until, pending_deletion_at, deletion_reason_code, \
content_hidden_since";
const USER_BATCH_SIZE: usize = 128;
const USER_BATCH_CONCURRENCY: usize = 8;
const USER_CACHE_MIN_GENERATION_STRIPES: usize = 4096;
@@ -167,10 +169,10 @@ struct FullUserDbRow {
last_voice_activity_sharing_change_at: OptionalTimestamp,
timezone: Option<String>,
timezone_privacy_flags: Option<i32>,
content_hidden_since: OptionalTimestamp,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "scylla", derive(DeserializeRow))]
struct PartialUserDbRow {
user_id: i64,
username: String,
@@ -185,6 +187,61 @@ struct PartialUserDbRow {
accent_color: Option<i32>,
avatar_color: Option<i32>,
mention_flags: Option<i32>,
#[serde(default)]
temp_banned_until: Option<i64>,
#[serde(default)]
pending_deletion_at: Option<i64>,
#[serde(default)]
deletion_reason_code: Option<i32>,
#[serde(default)]
content_hidden_since: Option<i64>,
}
#[cfg(feature = "scylla")]
#[derive(Debug, DeserializeRow)]
struct PartialUserScyllaRow {
user_id: i64,
username: String,
discriminator: i32,
global_name: Option<String>,
avatar_hash: Option<String>,
bot: Option<bool>,
system: Option<bool>,
flags: Option<i64>,
banner_hash: Option<String>,
banner_color: Option<i32>,
accent_color: Option<i32>,
avatar_color: Option<i32>,
mention_flags: Option<i32>,
temp_banned_until: OptionalTimestamp,
pending_deletion_at: OptionalTimestamp,
deletion_reason_code: Option<i32>,
content_hidden_since: OptionalTimestamp,
}
#[cfg(feature = "scylla")]
impl From<PartialUserScyllaRow> for PartialUserDbRow {
fn from(row: PartialUserScyllaRow) -> Self {
Self {
user_id: row.user_id,
username: row.username,
discriminator: row.discriminator,
global_name: row.global_name,
avatar_hash: row.avatar_hash,
bot: row.bot,
system: row.system,
flags: row.flags,
banner_hash: row.banner_hash,
banner_color: row.banner_color,
accent_color: row.accent_color,
avatar_color: row.avatar_color,
mention_flags: row.mention_flags,
temp_banned_until: optional_timestamp_millis(row.temp_banned_until),
pending_deletion_at: optional_timestamp_millis(row.pending_deletion_at),
deletion_reason_code: row.deletion_reason_code,
content_hidden_since: optional_timestamp_millis(row.content_hidden_since),
}
}
}
#[derive(Debug, Deserialize)]
@@ -245,6 +302,7 @@ struct FullUserKvRow {
last_voice_activity_sharing_change_at: Option<i64>,
timezone: Option<String>,
timezone_privacy_flags: Option<i32>,
content_hidden_since: Option<i64>,
}
fn generation_stripes(max_entries: u64) -> usize {
@@ -589,7 +647,9 @@ impl ScyllaUsersStorage {
.execute_unpaged(&self.stmt_partial, (user_id,))
.await?;
let rows = result.into_rows_result()?;
let partial = rows.maybe_first_row::<PartialUserDbRow>()?.map(Into::into);
let partial = rows
.maybe_first_row::<PartialUserScyllaRow>()?
.map(|row| UserPartial::from(PartialUserDbRow::from(row)));
Ok(partial)
}
@@ -599,9 +659,13 @@ impl ScyllaUsersStorage {
.execute_unpaged(&self.stmt_partial_batch, (user_ids,))
.await?;
let rows = result.into_rows_result()?;
let rows: Vec<PartialUserDbRow> =
rows.rows::<PartialUserDbRow>()?.collect::<Result<_, _>>()?;
Ok(rows.into_iter().map(UserPartial::from).collect::<Vec<_>>())
let rows: Vec<PartialUserScyllaRow> = rows
.rows::<PartialUserScyllaRow>()?
.collect::<Result<_, _>>()?;
Ok(rows
.into_iter()
.map(|row| UserPartial::from(PartialUserDbRow::from(row)))
.collect::<Vec<_>>())
}
}
@@ -675,6 +739,7 @@ fn fluxer_system_user() -> User {
last_voice_activity_sharing_change_at: None,
timezone: None,
timezone_privacy_flags: None,
content_hidden_since: None,
}
}
@@ -759,6 +824,12 @@ fn optional_date_string(value: OptionalDate) -> Option<String> {
impl From<PartialUserDbRow> for UserPartial {
fn from(row: PartialUserDbRow) -> Self {
let standing = AccountStanding {
flags: row.flags.unwrap_or_default(),
temp_banned_until_ms: row.temp_banned_until,
pending_deletion_at_ms: row.pending_deletion_at,
deletion_reason_code: row.deletion_reason_code,
};
Self {
user_id: row.user_id,
username: row.username,
@@ -773,7 +844,9 @@ impl From<PartialUserDbRow> for UserPartial {
accent_color: row.accent_color,
avatar_color: row.avatar_color,
mention_flags: row.mention_flags,
content_hidden_since: row.content_hidden_since,
}
.visible_to_others(&standing, now_ms())
}
}
@@ -856,6 +929,7 @@ impl From<FullUserDbRow> for User {
),
timezone: row.timezone,
timezone_privacy_flags: row.timezone_privacy_flags,
content_hidden_since: optional_timestamp_millis(row.content_hidden_since),
}
}
}
@@ -921,6 +995,7 @@ impl From<FullUserKvRow> for User {
last_voice_activity_sharing_change_at: row.last_voice_activity_sharing_change_at,
timezone: row.timezone,
timezone_privacy_flags: row.timezone_privacy_flags,
content_hidden_since: row.content_hidden_since,
}
}
}
@@ -1087,13 +1162,20 @@ mod tests {
.split(',')
.map(str::trim)
.collect::<BTreeSet<_>>();
let partial = serde_json::to_value(test_user(42).to_partial()).unwrap();
let fields = partial
let mut user = test_user(42);
user.content_hidden_since = Some(1);
let partial = serde_json::to_value(user.to_partial()).unwrap();
let mut fields = partial
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<BTreeSet<_>>();
fields.extend([
"temp_banned_until",
"pending_deletion_at",
"deletion_reason_code",
]);
let full_columns = FULL_USER_COLUMNS
.split(',')
.map(str::trim)
@@ -1146,4 +1228,146 @@ mod tests {
assert_eq!(user.premium_since, Some(1_781_526_896_789));
assert_eq!(user.version, 3);
}
const SPAMMER: i64 = 1 << 6;
const DISABLED: i64 = 1 << 38;
const PROFILE_HIDDEN: i64 = 1 << 7;
fn styled(user_id: i64, flags: i64) -> User {
let mut user = test_user(user_id);
user.flags = Some(flags);
user.banner_hash = Some("banner_hash".to_owned());
user.banner_color = Some(3);
user.accent_color = Some(5);
user.avatar_color = Some(9);
user
}
fn assert_hidden(partial: &UserPartial) {
assert_eq!(partial.username, "HiddenUser");
assert_eq!(partial.discriminator, 0);
assert_eq!(partial.global_name, None);
assert_eq!(partial.avatar_hash, None);
assert_eq!(partial.avatar_color, None);
assert_eq!(partial.banner_hash, None);
assert_eq!(partial.banner_color, None);
assert_eq!(partial.accent_color, None);
assert_ne!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
let api = partial.to_api_partial();
assert_eq!(
(api.username.as_str(), api.discriminator.as_str()),
("HiddenUser", "0000")
);
assert_ne!(api.flags & PROFILE_HIDDEN as i32, 0);
}
#[test]
fn hidden_and_enforced_accounts_get_a_neutral_partial_and_keep_their_stored_profile() {
let now = now_ms();
let mut banned = styled(43, DISABLED);
banned.temp_banned_until = Some(now + 60_000);
let mut deleting = styled(44, 0);
deleting.pending_deletion_at = Some(now + 60_000);
deleting.deletion_reason_code = Some(20);
for user in [
styled(41, PROFILE_HIDDEN),
styled(42, SPAMMER),
banned,
deleting,
] {
assert_hidden(&user.to_partial());
assert_eq!(user.username, "Ada");
assert_eq!(user.avatar_hash.as_deref(), Some("avatar_hash"));
}
}
#[test]
fn lifted_bans_self_disables_and_normal_accounts_keep_their_partial() {
let now = now_ms();
let mut expired = styled(45, DISABLED);
expired.temp_banned_until = Some(now - 60_000);
let mut self_deleting = styled(46, 0);
self_deleting.pending_deletion_at = Some(now + 60_000);
self_deleting.deletion_reason_code = Some(1);
for user in [styled(47, 0), styled(48, DISABLED), expired, self_deleting] {
let partial = user.to_partial();
assert_eq!(partial.username, "Ada", "{}", user.user_id);
assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace"));
assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash"));
assert_eq!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
}
}
#[test]
fn postgres_partial_rows_apply_the_mask_from_ban_state() {
let until = |ms: i64| {
let at = chrono::DateTime::from_timestamp_millis(ms).unwrap();
json!({"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)})
};
let row = |ms: i64| {
json!({
"user_id": {"__fluxer_type": "bigint", "value": "42"},
"username": "ada",
"discriminator": 7,
"global_name": "Ada",
"avatar_hash": "avatar_hash",
"flags": {"__fluxer_type": "bigint", "value": DISABLED.to_string()},
"temp_banned_until": until(ms),
})
};
let now = now_ms();
assert_hidden(&decode_postgres_user_partial(row(now + 60_000)).unwrap());
let lifted = decode_postgres_user_partial(row(now - 60_000)).unwrap();
assert_eq!(lifted.username, "ada");
assert_eq!(lifted.avatar_hash.as_deref(), Some("avatar_hash"));
}
#[test]
fn the_message_hide_window_reaches_the_partial_independently_of_the_profile_mask() {
let since = 1_790_000_000_000;
let mut hidden = styled(49, 0);
hidden.content_hidden_since = Some(since);
let partial = hidden.to_partial();
assert_eq!(partial.content_hidden_since, Some(since));
assert_eq!(partial.username, "Ada");
let mut masked = styled(50, PROFILE_HIDDEN);
masked.content_hidden_since = Some(since);
let partial = masked.to_partial();
assert_hidden(&partial);
assert_eq!(partial.content_hidden_since, Some(since));
assert_eq!(
styled(51, PROFILE_HIDDEN).to_partial().content_hidden_since,
None
);
let wire = serde_json::to_value(styled(52, 0).to_partial()).unwrap();
assert!(wire.get("content_hidden_since").is_none());
assert!(
serde_json::to_value(hidden.to_partial().to_api_partial())
.unwrap()
.get("content_hidden_since")
.is_none()
);
}
#[test]
fn postgres_partial_rows_read_the_message_hide_window() {
let since = 1_790_000_000_000_i64;
let at = chrono::DateTime::from_timestamp_millis(since).unwrap();
let row = json!({
"user_id": {"__fluxer_type": "bigint", "value": "42"},
"username": "ada",
"discriminator": 7,
"content_hidden_since": {"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)},
});
let partial = decode_postgres_user_partial(row).unwrap();
assert_eq!(partial.content_hidden_since, Some(since));
assert_eq!(partial.username, "ada");
let cleared = decode_postgres_user_partial(json!({
"user_id": {"__fluxer_type": "bigint", "value": "42"},
"username": "ada",
"discriminator": 7,
}))
.unwrap();
assert_eq!(cleared.content_hidden_since, None);
}
}
+50 -1
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::user_flags::{
AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags,
};
#[cfg(test)]
use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN};
use fluxer_common::user_flags::{USER_FLAG_STAFF, visible_user_flags};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -99,6 +101,8 @@ pub struct User {
pub last_voice_activity_sharing_change_at: Option<i64>,
pub timezone: Option<String>,
pub timezone_privacy_flags: Option<i32>,
#[serde(default)]
pub content_hidden_since: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -116,6 +120,8 @@ pub struct UserPartial {
pub accent_color: Option<i32>,
pub avatar_color: Option<i32>,
pub mention_flags: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hidden_since: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -139,8 +145,30 @@ const FLUXER_SYSTEM_USER_ID: i64 = 0;
const FLUXER_SYSTEM_USERNAME: &str = "Fluxer";
const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000";
const HIDDEN_USER_USERNAME: &str = "HiddenUser";
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
impl User {
pub fn standing(&self) -> AccountStanding {
AccountStanding {
flags: self.flags.unwrap_or_default(),
temp_banned_until_ms: self.temp_banned_until,
pending_deletion_at_ms: self.pending_deletion_at,
deletion_reason_code: self.deletion_reason_code,
}
}
pub fn to_partial(&self) -> UserPartial {
self.to_own_partial()
.visible_to_others(&self.standing(), now_ms())
}
fn to_own_partial(&self) -> UserPartial {
UserPartial {
user_id: self.user_id,
username: self.username.clone(),
@@ -155,11 +183,30 @@ impl User {
accent_color: self.accent_color,
avatar_color: self.avatar_color,
mention_flags: self.mention_flags,
content_hidden_since: self.content_hidden_since,
}
}
}
impl UserPartial {
pub fn visible_to_others(self, standing: &AccountStanding, now_ms: i64) -> UserPartial {
if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) {
return self;
}
UserPartial {
username: HIDDEN_USER_USERNAME.to_owned(),
discriminator: 0,
global_name: None,
avatar_hash: None,
flags: Some(self.flags.unwrap_or_default() | USER_FLAG_PROFILE_HIDDEN),
banner_hash: None,
banner_color: None,
accent_color: None,
avatar_color: None,
..self
}
}
pub fn to_api_partial(&self) -> ApiUserPartial {
if self.user_id == FLUXER_SYSTEM_USER_ID {
return fluxer_system_user();
@@ -233,6 +280,7 @@ mod tests {
accent_color: None,
avatar_color: Some(0x336699),
mention_flags: Some(0),
content_hidden_since: None,
}
}
@@ -294,6 +342,7 @@ mod tests {
last_voice_activity_sharing_change_at: None,
timezone: Some("Europe/London".to_owned()),
timezone_privacy_flags: Some(1),
content_hidden_since: None,
}
}