mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): add moderation events and visibility actions (#3219)
This commit is contained in:
+234
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user