feat(admin): require a resolution and fix panel layout and copy (#3335)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:40:34 +02:00
1 parent 4db13b0375
commit 6534ee4300
57 files changed
+1152 -221

No files matched your search

+34
View File
@@ -17,6 +17,7 @@ pub mod user_flag_bits {
pub const FRIENDLY_BOT: u64 = 1 << 4;
pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5;
pub const SPAMMER: u64 = 1 << 6;
pub const PROFILE_HIDDEN: u64 = 1 << 7;
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
pub const DELETED: u64 = 1 << 34;
pub const SELF_DELETED: u64 = 1 << 36;
@@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "SPAMMER",
value: user_flag_bits::SPAMMER,
},
U64Flag {
name: "PROFILE_HIDDEN",
value: user_flag_bits::PROFILE_HIDDEN,
},
U64Flag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT,
@@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
value: 1 << 8,
},
];
#[cfg(test)]
mod tests {
use super::USER_FLAGS;
#[test]
fn user_flags_cover_every_flag_in_the_admin_spec() {
let spec: serde_json::Value =
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"]
.as_array()
.expect("UserFlags bitflag values");
assert!(!values.is_empty());
for entry in values {
let name = entry["name"].as_str().expect("flag name");
let value: u64 = entry["value"]
.as_str()
.expect("flag value")
.parse()
.expect("numeric flag value");
assert!(
USER_FLAGS
.iter()
.any(|flag| flag.name == name && flag.value == value),
"{name} ({value}) is missing from USER_FLAGS"
);
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -41,11 +41,12 @@ impl AdminApiClient {
pub async fn resolve_report(
&self,
report_id: &str,
resolution: UpdateReportRequestResolution,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved"});
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
+311 -2
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::{
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
read_first_env, trim_trailing_slash,
};
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com";
#[derive(Clone, Debug)]
pub struct AdminConfig {
@@ -17,6 +18,7 @@ pub struct AdminConfig {
pub api_endpoint: String,
pub media_endpoint: String,
pub static_cdn_endpoint: String,
pub reports_bucket_origin: String,
pub admin_endpoint: String,
pub web_app_endpoint: String,
pub oauth_client_id: String,
@@ -76,6 +78,7 @@ impl AdminConfig {
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
reports_bucket_origin: reports_bucket_origin_from_env(),
admin_endpoint,
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
@@ -120,6 +123,72 @@ impl AdminConfig {
}
}
fn reports_bucket_origin_from_env() -> String {
let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT")
.map(|value| normalize_public_endpoint_from_env(value.trim()));
let endpoint = env_value("FLUXER_S3_ENDPOINT");
presign_endpoint(
public_endpoint.as_deref(),
endpoint.as_deref(),
&read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"),
)
.and_then(|endpoint| {
bucket_origin(
&endpoint,
read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false),
&read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"),
)
})
.unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned())
}
fn presign_endpoint(
public_endpoint: Option<&str>,
endpoint: Option<&str>,
uploads_bucket: &str,
) -> Option<url::Url> {
let Some(public_endpoint) = public_endpoint else {
return url::Url::parse(endpoint?.trim()).ok();
};
let mut parsed = url::Url::parse(public_endpoint).ok()?;
let host = parsed.host_str()?.to_owned();
if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) {
parsed.set_host(Some(shared_host)).ok()?;
}
Some(parsed)
}
fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option<String> {
if !matches!(endpoint.scheme(), "http" | "https") {
return None;
}
let path_style = force_path_style
|| !matches!(endpoint.host(), Some(url::Host::Domain(_)))
|| !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http");
if path_style {
return Some(endpoint.origin().ascii_serialization());
}
let mut virtual_host = endpoint.clone();
virtual_host
.set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?)))
.ok()?;
Some(virtual_host.origin().ascii_serialization())
}
fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
if allow_dots && bucket.contains('.') {
return bucket
.split('.')
.all(|label| is_virtual_hostable_bucket(label, false));
}
(3..=63).contains(&bucket.len())
&& bucket
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !bucket.starts_with('-')
&& !bucket.ends_with('-')
}
impl RuntimeEnv {
pub(crate) fn from_env_value(value: &str) -> Self {
match value {
@@ -138,7 +207,7 @@ mod tests {
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 10] = [
const MANAGED_ENV: [&str; 15] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
@@ -149,6 +218,11 @@ mod tests {
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
"FLUXER_S3_ENDPOINT",
"FLUXER_S3_PUBLIC_ENDPOINT",
"FLUXER_S3_FORCE_PATH_STYLE",
"FLUXER_S3_BUCKET_UPLOADS",
"FLUXER_S3_BUCKET_REPORTS",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
@@ -238,6 +312,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -266,6 +341,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -368,4 +444,237 @@ mod tests {
format!("{api_admin_endpoint}/oauth2_callback")
);
}
fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option<String> {
bucket_origin(
&url::Url::parse(endpoint).expect("valid endpoint"),
force_path_style,
bucket,
)
}
#[test]
fn bucket_origin_matches_the_addressing_of_presigned_urls() {
let cases = [
(
"https://ewr1.vultrobjects.com",
false,
"fluxer-reports",
"https://fluxer-reports.ewr1.vultrobjects.com",
),
(
"https://ewr1.vultrobjects.com/",
true,
"fluxer-reports",
"https://ewr1.vultrobjects.com",
),
(
"http://seaweedfs:8333",
true,
"fluxer-reports",
"http://seaweedfs:8333",
),
(
"http://seaweedfs:8333",
false,
"fluxer-reports",
"http://fluxer-reports.seaweedfs:8333",
),
(
"http://127.0.0.1:8333",
false,
"fluxer-reports",
"http://127.0.0.1:8333",
),
(
"http://[::1]:8333",
false,
"fluxer-reports",
"http://[::1]:8333",
),
(
"https://s3.example.com:9000",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com:9000",
),
(
"https://s3.example.com:443/base/path",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://S3.Example.com",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://s3.example.com",
false,
"reports.example",
"https://s3.example.com",
),
(
"http://s3.example.com",
false,
"reports.example",
"http://reports.example.s3.example.com",
),
(
"http://s3.example.com",
false,
"a.example",
"http://s3.example.com",
),
(
"https://s3.example.com",
false,
"Reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"ab",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"reports_bucket",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"-reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"192.168.1.1",
"https://s3.example.com",
),
];
for (endpoint, force_path_style, bucket, expected) in cases {
assert_eq!(
origin_for(endpoint, force_path_style, bucket).as_deref(),
Some(expected),
"{endpoint} {force_path_style} {bucket}"
);
}
assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None);
}
#[test]
fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() {
let host = |public: Option<&str>, endpoint: Option<&str>| {
presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string())
};
assert_eq!(
host(
Some("https://fluxer-uploads.ewr1.vultrobjects.com"),
Some("https://internal.example")
)
.as_deref(),
Some("https://ewr1.vultrobjects.com/")
);
assert_eq!(
host(
Some("https://cdn.example.com"),
Some("http://seaweedfs:8333")
)
.as_deref(),
Some("https://cdn.example.com/")
);
assert_eq!(
host(None, Some("http://seaweedfs:8333")).as_deref(),
Some("http://seaweedfs:8333/")
);
assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None);
assert_eq!(host(None, None), None);
}
#[test]
fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() {
let config = config_from_env(&[]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
}
#[test]
fn the_reports_bucket_origin_follows_the_object_store_settings() {
let hosted = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://fluxer-uploads.ewr1.vultrobjects.com",
),
]);
assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let bundled = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://objects.fluxer.example",
),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(
bundled.reports_bucket_origin,
"https://objects.fluxer.example"
);
let internal_only = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333");
let outside = config_from_env(&[
(
"FLUXER_S3_ENDPOINT",
"https://s3.eu-central-1.amazonaws.com",
),
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
outside.reports_bucket_origin,
"https://example-reports.s3.eu-central-1.amazonaws.com"
);
let renamed_uploads = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://s3.example.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://example-uploads.s3.example.com",
),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
renamed_uploads.reports_bucket_origin,
"https://example-reports.s3.example.com"
);
}
#[test]
fn a_non_default_public_port_reaches_the_reports_bucket_origin() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080");
}
}
+1
View File
@@ -213,6 +213,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
+8 -1
View File
@@ -4,6 +4,7 @@ use crate::api::client::{AdminApiClient, ApiResult};
use crate::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext;
use crate::templates;
use crate::utils::timestamps::format_admin_timestamp;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
@@ -201,7 +202,13 @@ async fn execute_check(
};
match result {
Ok(r) if r.banned => match r.expires_at {
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
Some(expires_at) => (
"info",
format!(
"{value} is banned until {}",
format_admin_timestamp(&expires_at)
),
),
None => ("info", format!("{value} is banned")),
},
Ok(_) => ("info", format!("{value} is NOT banned")),
+4 -1
View File
@@ -150,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String {
"default-src 'self'".to_owned(),
"script-src 'self' 'unsafe-inline'".to_owned(),
"style-src 'self' 'unsafe-inline'".to_owned(),
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
format!(
"img-src 'self' data: blob: {static_cdn} {media} {}",
config.reports_bucket_origin
),
"font-src 'self'".to_owned(),
"connect-src 'self'".to_owned(),
"object-src 'none'".to_owned(),
+29 -29
View File
@@ -22,7 +22,6 @@ use axum::{
Form, Router,
extract::{FromRequest, Path, Query, Request, State},
http::HeaderMap,
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
@@ -69,6 +68,8 @@ struct ResolveForm {
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
public_comment: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
@@ -287,53 +288,52 @@ async fn report_resolve(
) -> Response {
let config = state.config();
let base = &config.base_path;
let is_background = request
.uri()
.query()
.is_some_and(|query| query.split('&').any(|part| part == "background=1"));
let back = format!("{base}/reports/{report_id}");
let form: ResolveForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report resolve form");
return flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let Some((resolution, label)) =
templates::pages::report_detail::resolution_choice(form.resolution.as_deref())
else {
return flash::redirect_with_flash(
&back,
FlashData::error(RESOLUTION_REQUIRED),
config.secure_cookies(),
);
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
if is_background {
return StatusCode::NO_CONTENT.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::success("Report resolved"),
config.secure_cookies(),
let flash = match client
.resolve_report(
&report_id,
resolution,
public_comment.as_deref(),
notify_reporter,
None,
)
}
.await
{
Ok(_) => FlashData::success(format!("Report resolved: {label}")),
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: resolve report");
if is_background {
return StatusCode::BAD_GATEWAY.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Failed to resolve report"),
config.secure_cookies(),
)
}
FlashData::error("Failed to resolve report")
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
async fn report_legal_hold(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
+7 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
admin_flags,
api::client::{AdminApiClient, ApiError},
@@ -311,7 +312,7 @@ pub async fn dispatch(
};
if !form.bool_value("confirm") {
return DispatchOutcome::error(
"Confirm whose deletion you are cancelling before submitting",
"Confirm whose deletion you are canceling before submitting",
);
}
let Some(private_reason) = get("private_reason") else {
@@ -322,7 +323,7 @@ pub async fn dispatch(
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
.await
{
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The pending deletion changed since this page loaded. Reload and review.",
),
@@ -435,8 +436,9 @@ pub async fn dispatch(
match client.delete_all_user_messages(user_id, dry_run).await {
Ok(response) if dry_run => DispatchOutcome {
flash: FlashData::success(format!(
"Dry run found {} messages across {} channels. Confirm to delete them permanently.",
response.message_count, response.channel_count
"Dry run found {} across {}. Confirm to delete them permanently.",
count_noun(response.message_count, "message", "messages"),
count_noun(response.channel_count, "channel", "channels")
)),
redirect_params: vec![
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
@@ -470,7 +472,7 @@ pub async fn dispatch(
}
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
client.cancel_bulk_message_deletion(user_id).await,
"Bulk message deletion cancelled successfully",
"Bulk message deletion canceled successfully",
"Failed to cancel bulk message deletion",
),
"message_shred" => {
+6
View File
@@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 {
input, select, textarea {
font-size: 16px;
}
select,
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) {
height: auto;
min-height: 2.5rem;
}
}
a:focus-visible,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
templates::components::form::{checkbox, csrf_input, submit_button},
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@for archive in archives {
tr {
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
(archive.requested_at)
(format_admin_timestamp(&archive.requested_at))
}
td class="px-4 py-3 text-sm text-neutral-900" {
(status_text(archive))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::AuditLogEntry,
templates::pages::audit_logs_table::{
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(log.created_at)
(format_admin_timestamp(&log.created_at))
}
}))
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
@@ -222,7 +222,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
div class="checkbox-custom" {
(PreEscaped(CHECKMARK_SVG))
}
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
}
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::user_flag_bits;
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub mod premium_types {
@@ -22,18 +23,21 @@ fn premium_tooltip(
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
Some(since) => format!(
"Fluxer Plutonium subscriber since {}",
format_admin_timestamp(since)
),
None => "Fluxer Plutonium".into(),
}
})
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
config::AdminConfig,
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
"Key ID: " (key_id)
}
p class="text-sm text-neutral-500" {
"Created: " (created_at)
"Created: " (format_admin_timestamp(created_at))
}
p class="text-sm text-neutral-500" {
"Created by: " (key.created_by_user_id)
}
@if let Some(ref expires_at) = key.expires_at {
p class="text-sm text-neutral-500" {
"Expires: " (expires_at)
"Expires: " (format_admin_timestamp(expires_at))
}
}
@if !key.acls.is_empty() {
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
config::AdminConfig,
@@ -69,7 +70,7 @@ fn archive_table(archives: &[Archive], base: &str) -> Markup {
span class="text-sm" { (archive.requested_by) }
}))
(table_cell(false, html! {
span class="text-sm" { (archive.requested_at) }
span class="text-sm" { (format_admin_timestamp(&archive.requested_at)) }
}))
(table_cell(false, html! {
div class="flex flex-col gap-1" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
api::types::AuditLogsListResponse,
config::AdminConfig,
@@ -120,7 +121,12 @@ pub fn audit_logs_page(
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total);
let showing = format!(
"Showing {} of {} {}",
entries.len(),
total,
noun_for(total, "entry", "entries")
);
html! {
(page_header_with_actions("Audit Logs", None, html! {
span class="text-sm text-neutral-500" { (showing) }
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{
AuditLogChannelSummary, AuditLogEntry, AuditLogGuildSummary, AuditLogUserSummary,
@@ -273,7 +274,7 @@ pub fn log_row(base: &str, entry: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(entry.created_at)
(format_admin_timestamp(&entry.created_at))
}
}))
(table_cell(false, badge(&format_action(&entry.action), action_badge_variant(&entry.action))))
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -35,7 +36,7 @@ pub fn ip_ban_duration_label(hours: u32) -> String {
.iter()
.find(|(value, _)| *value == hours)
.map_or_else(
|| format!("{hours} hours"),
|| count_noun(u64::from(hours), "hour", "hours"),
|(_, label)| (*label).to_owned(),
)
}
@@ -72,6 +72,10 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "SPAMMER",
value: 1 << 6,
},
UserFlag {
name: "PROFILE_HIDDEN",
value: 1 << 7,
},
UserFlag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: 1 << 33,
+2 -1
View File
@@ -3,6 +3,7 @@
use super::gateway_leaderboard;
use super::gateway_nodes;
use crate::utils::plural::count_noun;
use crate::{
api::types::{GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse},
config::AdminConfig,
@@ -43,7 +44,7 @@ pub fn gateway_page(config: &AdminConfig, auth: &AuthContext, p: &GatewayPagePar
))
@if let Some(count) = p.reload_result {
div class="rounded-lg border p-4 bg-green-50 border-green-200 text-green-700" {
"Successfully reloaded " (count) " guilds!"
"Successfully reloaded " (count_noun(count, "guild", "guilds")) "!"
}
}
div class="space-y-6" {
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{GuildInfo, GuildMember, ListGuildMembersResponse},
config::AdminConfig,
@@ -149,11 +151,11 @@ fn member_card(
"ID: " (member.user.id)
}
p class="text-sm text-neutral-500" {
"Joined: " (member.joined_at)
"Joined: " (format_admin_timestamp(&member.joined_at))
}
@if !member.roles.is_empty() {
p class="text-sm text-neutral-500" {
(member.roles.len()) " roles"
(count_noun(member.roles.len() as u64, "role", "roles"))
}
}
}
@@ -4,9 +4,10 @@ use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::{
components::{page_container::card_with_header, table::data_table},
components::page_container::card_with_header,
pages::user_detail_tabs::reports::{
format_status, reported_at_cell, reporter_label, type_and_category_cell,
format_status, report_tab_table, reported_at_cell, reporter_label,
type_and_category_cell,
},
},
};
@@ -43,7 +44,7 @@ pub fn reports_tab(
}
}))
} @else {
(data_table(
(report_tab_table(
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
html! {
@for report in reports {
@@ -82,7 +83,7 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(ref rid) = report.reporter_id {
a href={(base) "/users/" (rid)}
class="hover:underline" {
@@ -92,10 +93,13 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
span { (reporter_label(report)) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(format_status(report.status))
}
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
(format_status(report.status))
}
td class="whitespace-nowrap px-4 py-3 text-sm" {
a href={(base) "/reports/" (report.report_id)}
class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 \
@@ -138,14 +142,17 @@ mod tests {
"{markup}"
);
assert!(markup.contains("Avery Reporter "), "{markup}");
assert_eq!(
markup.matches("whitespace-nowrap px-4").count(),
2,
assert!(
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
"{markup}"
);
assert!(
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
"{markup}"
);
assert!(markup.contains(">Pending<"), "{markup}");
assert!(markup.contains(">View<"), "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 1, "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -97,7 +98,7 @@ pub fn guilds_list_page(
} @else if let Some(count) = result_state.total {
(page_header_with_actions("Guilds", None, html! {
p class="text-sm font-normal text-neutral-500" {
"Found " (count) (if count == 1 { " result" } else { " results" }) " (showing "
"Found " (count_noun(count, "result", "results")) " (showing "
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
")"
}
@@ -654,7 +654,7 @@ fn integrations_config_section(
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
@@ -1364,7 +1364,7 @@ fn experiment_delivery_section(
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
spreads a fleet that has synchronized on one tick back out across the interval.",
html! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
@@ -17,7 +17,7 @@ use crate::{
};
use maud::{Markup, html};
use super::jobs_list_helpers::status_badge;
use super::jobs_list_helpers::{job_time, status_badge};
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -113,10 +113,10 @@ fn overview_section(job: &serde_json::Value) -> Markup {
html! {
(data_field_mono("Task", task_type))
(data_field_mono("Lane", if lane.is_empty() { "\u{2014}" } else { lane }))
(data_field_text("Created", if created_at.is_empty() { "\u{2014}" } else { created_at }))
(data_field_text("Started", if started_at.is_empty() { "\u{2014}" } else { started_at }))
(data_field_text("Completed", if completed_at.is_empty() { "\u{2014}" } else { completed_at }))
(data_field_text("Run-at", run_at))
(data_field_text("Created", &job_time(created_at)))
(data_field_text("Started", &job_time(started_at)))
(data_field_text("Completed", &job_time(completed_at)))
(data_field_text("Run-at", &job_time(run_at)))
(data_field_text("Attempts", &format!("{attempts}/{max_attempts}")))
(data_field_mono("Scheduled by", requester_display))
(data_field_text("Audit reason", if audit_reason.is_empty() { "\u{2014}" } else { audit_reason }))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -25,7 +26,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
(select_input("status", "Status", &[
("", "Any"), ("queued", "Queued"), ("running", "Running"),
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
("succeeded", "Succeeded"), ("cancelled", "Canceled"),
("deadletter", "Dead-letter"),
], p.status_filter))
div class="flex flex-col gap-2" {
@@ -69,8 +70,8 @@ pub struct JobsListParams<'a> {
pub fn jobs_list_page(config: &AdminConfig, auth: &AuthContext, params: &JobsListParams) -> Markup {
let base = &config.base_path;
let count_label = format!(
"{} jobs{}",
params.jobs.len(),
"{}{}",
count_noun(params.jobs.len() as u64, "job", "jobs"),
if params.next_cursor.is_some() {
" (more available)"
} else {
@@ -4,18 +4,28 @@ use crate::templates::components::{
badge::{BadgeVariant, badge},
table::{data_table, table_cell, table_row},
};
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub(crate) fn status_badge(status: &str) -> Markup {
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
let (label, variant) = match status {
"queued" => ("Queued", BadgeVariant::Default),
"running" => ("Running", BadgeVariant::Info),
"succeeded" => ("Succeeded", BadgeVariant::Success),
"failed" => ("Failed", BadgeVariant::Danger),
"deadletter" => ("Dead-letter", BadgeVariant::Danger),
"cancelled" => ("Canceled", BadgeVariant::Warning),
other => (other, BadgeVariant::Default),
};
badge(status, variant)
badge(label, variant)
}
pub(crate) fn job_time(value: &str) -> String {
match value {
"" | "\u{2014}" => "\u{2014}".to_owned(),
"immediate" => "Immediate".to_owned(),
other => format_admin_timestamp(other),
}
}
pub(crate) fn format_progress(job: &serde_json::Value) -> String {
@@ -82,7 +92,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
table_row(html! {
(table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) }
span class="whitespace-nowrap text-sm" { (job_time(created_at)) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (job_id)}
@@ -186,3 +196,22 @@ pub(crate) fn next_page_link(
}
}
}
#[cfg(test)]
mod tests {
use super::status_badge;
#[test]
fn status_badges_use_us_spelling_and_readable_labels() {
let canceled = status_badge("cancelled").into_string();
assert!(canceled.contains(">Canceled<"));
assert!(!canceled.contains("Cancelled"));
assert!(
status_badge("deadletter")
.into_string()
.contains(">Dead-letter<")
);
assert!(status_badge("running").into_string().contains(">Running<"));
assert!(status_badge("mystery").into_string().contains(">mystery<"));
}
}
@@ -2,9 +2,12 @@
use crate::{
acl,
api::types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry, ReportFlowAnswersEntry,
ReportProfileSnapshot, ReportProfileSnapshotAsset,
api::{
generated::types::UpdateReportRequestResolution,
types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry,
ReportFlowAnswersEntry, ReportProfileSnapshot, ReportProfileSnapshotAsset,
},
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -15,7 +18,9 @@ use crate::{
data_field, data_field_link_mono, data_field_mono, data_field_muted,
data_field_text,
},
form::{FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox},
form::{
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox, select_chevron,
},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
@@ -576,19 +581,42 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
}
}
const RESOLUTION_CHOICES: [(UpdateReportRequestResolution, &str); 3] = [
(UpdateReportRequestResolution::Actioned, "Action taken"),
(UpdateReportRequestResolution::NoViolation, "No violation"),
(UpdateReportRequestResolution::Duplicate, "Duplicate"),
];
pub fn resolution_choice(
value: Option<&str>,
) -> Option<(UpdateReportRequestResolution, &'static str)> {
let value = value?.trim();
RESOLUTION_CHOICES
.into_iter()
.find(|(resolution, _)| resolution.to_string() == value)
}
fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup {
html! {
form method="post" action={(base) "/reports/" (report_id) "/resolve"} class="flex flex-col gap-3"
data-admin-refresh-on-success="#report-detail" {
(csrf_input(csrf_token))
label for="resolution" class="block text-sm font-medium text-neutral-700" {
label for="resolution" class=(FORM_LABEL_CLASS) { "Resolution" }
div class="relative" {
select id="resolution" name="resolution" required
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
option value="" selected disabled { "Choose one" }
@for (resolution, label) in RESOLUTION_CHOICES {
option value=(resolution) { (label) }
}
}
(select_chevron())
}
label for="public_comment" class=(FORM_LABEL_CLASS) {
"Public comment to the reporter (optional)"
}
textarea id="resolution" name="resolution" rows="3" maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {}
textarea id="public_comment" name="public_comment" rows="3" maxlength="512"
class={(FORM_CONTROL_CLASS) " px-3 py-2"} {}
(opt_out_checkbox("notify_reporter", "Include the public comment in the reporter notice"))
p class="text-neutral-500 text-xs" data-resolve-notice-help="" {
"The reporter is told the report was resolved either way, unless their account is gone or barred from reporting. \
@@ -1142,6 +1170,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -1741,7 +1770,7 @@ mod tests {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
assert!(markup.contains(r##"data-admin-refresh-on-success="#report-detail""##));
assert!(markup.contains(r#"name="resolution""#));
assert!(markup.contains(r#"<textarea id="public_comment" name="public_comment""#));
assert!(markup.contains(r#"maxlength="512""#));
assert!(markup.contains(r#"name="notify_reporter" value="true" checked"#));
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
@@ -1760,6 +1789,57 @@ mod tests {
assert!(!markup.contains("Notify the reporter"), "{markup}");
}
#[test]
fn resolve_form_requires_one_of_the_three_resolutions() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(
markup.contains(r#"<select id="resolution" name="resolution" required"#),
"{markup}"
);
assert!(
markup.contains(r#"<option value="" selected disabled>Choose one</option>"#),
"{markup}"
);
let options: Vec<&str> = markup
.split("<option value=\"")
.skip(2)
.map(|option| option.split("</option>").next().unwrap_or(""))
.collect();
assert_eq!(
options,
[
r#"actioned">Action taken"#,
r#"no_violation">No violation"#,
r#"duplicate">Duplicate"#
]
);
}
#[test]
fn resolution_choice_accepts_only_the_wire_values() {
assert_eq!(
resolution_choice(Some("actioned")),
Some((UpdateReportRequestResolution::Actioned, "Action taken"))
);
assert_eq!(
resolution_choice(Some(" no_violation ")),
Some((UpdateReportRequestResolution::NoViolation, "No violation"))
);
assert_eq!(
resolution_choice(Some("duplicate")),
Some((UpdateReportRequestResolution::Duplicate, "Duplicate"))
);
for missing in [
None,
Some(""),
Some("Action taken"),
Some("auto_resolved"),
Some("unspecified"),
] {
assert_eq!(resolution_choice(missing), None, "{missing:?}");
}
}
#[test]
fn reported_user_shows_a_bot_badge_only_for_bot_accounts() {
let config = test_config();
+147 -11
View File
@@ -20,7 +20,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs::reports::tagged_user,
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
utils::{plural::count_noun, timestamps::format_admin_timestamp, user_tag::user_tag},
};
use maud::{Markup, PreEscaped, html};
@@ -80,7 +80,7 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
html! {
@if let Some(result) = result {
p class="text-neutral-500 text-sm" {
"Found " (result.total) (if result.total == 1 { " result" } else { " results" })
"Found " (count_noun(result.total, "result", "results"))
" (showing " (result.reports.len()) ")"
}
}
@@ -405,7 +405,7 @@ fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
div class="flex flex-col items-start gap-0.5" {
(webhook_identity(webhook))
a href=(webhook.reports_href) title="Reports about this webhook"
class="font-mono text-blue-600 text-xs hover:underline" {
class="font-mono text-blue-600 text-xs [overflow-wrap:normal] hover:underline" {
"Webhook ID: " (webhook.id)
}
@if let Some(ref creator) = webhook.creator {
@@ -452,11 +452,14 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
let rows = html! {
@for report in reports {
tr class="hover:bg-neutral-50 transition-colors" {
td class="whitespace-nowrap px-3 py-3 text-neutral-600 text-sm" {
td class="hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell" {
(format_admin_timestamp(&report.reported_at))
}
td class="px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
(format_admin_timestamp(&report.reported_at))
}
@let (type_label, type_variant) = format_report_type(report.report_type);
(badge(type_label, type_variant))
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
@@ -486,20 +489,23 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
}
}
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reporter_cell(config, report))
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reported_cell(config, report))
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
@let (label, variant) = format_status(report.status);
@let (status_label, status_variant) = format_status(report.status);
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
span data-status-pill=(report.report_id) {
(badge(label, variant))
(badge(status_label, status_variant))
}
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="xl:hidden" data-status-pill-compact=(report.report_id) {
(badge(status_label, status_variant))
}
button type="button"
data-drawer-open="report-peek"
data-drawer-href={(base) "/reports/" (report.report_id) "/fragment"}
@@ -530,8 +536,15 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
(table_container(table(html! {
(table_head(html! {
tr {
@for header in headers {
th class="whitespace-nowrap px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" {
@for (index, header) in headers.iter().enumerate() {
th class={"px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" (match index {
0 | 4 => " hidden whitespace-nowrap xl:table-cell",
5 => " xl:whitespace-nowrap",
_ => " whitespace-nowrap",
})} {
@if index == 5 {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
@@ -627,3 +640,126 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
.join("&");
format!("{}/reports?{}", config.base_path, query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProxyConfig, RuntimeEnv};
use serde_json::json;
fn test_config() -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: String::new(),
port: 0,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn report() -> ReportEntry {
let mut value = json!({
"report_id": "1556709309709027556",
"reporter_id": "1556709306000000001",
"reporter_username": "reporter_80f33d09e88a",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "5193",
"reported_at": "2026-10-06T04:33:00Z",
"status": 0,
"report_type": 1,
"category": "harassment",
"reported_user_id": "1556709306000000002",
"reported_user_username": "target_bc57b33ca5c4",
"reported_user_discriminator": "8316"
});
for key in [
"reporter_tag",
"reporter_email",
"reporter_full_legal_name",
"reporter_country_of_residence",
"additional_info",
"reported_user_tag",
"reported_user_global_name",
"reported_user_avatar_hash",
"reported_guild_id",
"reported_guild_name",
"reported_guild_icon_hash",
"reported_message_id",
"reported_channel_id",
"reported_channel_name",
"reported_channel_nsfw",
"reported_guild_invite_code",
"reported_guild_nsfw_level",
"reported_guild_nsfw",
"reported_guild_content_warning_level",
"reported_guild_content_warning_text",
"reported_channel_nsfw_override",
"reported_channel_content_warning_level",
"reported_channel_content_warning_text",
"reported_channel_effective_nsfw",
"reported_channel_effective_content_warning_level",
"reported_channel_effective_content_warning_text",
"resolved_at",
"resolved_by_admin_id",
"public_comment",
"mutual_dm_channel_id",
"message_context",
] {
value[key] = serde_json::Value::Null;
}
serde_json::from_value(value).expect("report fixture")
}
#[test]
fn narrow_layout_moves_date_and_status_into_visible_columns() {
let markup = render_reports_table(&test_config(), &[report()]).into_string();
assert!(markup.contains(
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
));
assert!(markup.contains(
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
));
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
assert_eq!(
markup
.matches("hidden whitespace-nowrap xl:table-cell")
.count(),
2
);
assert_eq!(
markup
.matches(
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
)
.count(),
1
);
assert_eq!(
markup
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
.count(),
1
);
assert_eq!(
markup
.matches("[overflow-wrap:anywhere] [&amp;_.whitespace-nowrap]:whitespace-normal")
.count(),
2
);
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -139,7 +140,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
@if status_str == "completed" {
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
p class="text-sm text-neutral-700" {
"Indexed " (idx) " / " (tot) " items"
"Indexed " (idx) " / " (tot) " " (noun_for(tot, "item", "items"))
}
}
}
@@ -16,6 +16,7 @@ use crate::{
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
},
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -160,7 +161,7 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
}))
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().map(format_admin_timestamp).unwrap_or_default()) }))
(table_cell(false, html! {
form method="post" action=(action_url) {
(csrf_input(csrf_token))
@@ -216,11 +216,11 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
span class="ml-2 inline-flex items-center rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600" { "Terminated" }
}
}))
(meta_cell("Created", html! { (s.created_at) }))
(meta_cell("Created", html! { (format_admin_timestamp(&s.created_at)) }))
@if let Some(ref d) = s.deleted_at {
(meta_cell("Terminated", html! { (d) }))
(meta_cell("Terminated", html! { (format_admin_timestamp(d)) }))
} @else {
(meta_cell("Last Used", html! { (s.approx_last_used_at) }))
(meta_cell("Last Used", html! { (format_admin_timestamp(&s.approx_last_used_at)) }))
}
div class="md:col-span-3" {
(meta_cell("IP Address", html! {
@@ -388,11 +388,11 @@ fn webauthn_credential_row(
p class="text-sm text-neutral-900" { (credential.name) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" { (credential.created_at) }
p class="text-sm text-neutral-900" { (format_admin_timestamp(&credential.created_at)) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" {
(credential.last_used_at.as_deref().unwrap_or("Never"))
(credential.last_used_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}
}
td class="py-2" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::DmChannel, config::AdminConfig,
templates::components::page_container::card_with_header,
@@ -85,7 +86,7 @@ fn group_dm_card(base: &str, user_id: &str, channel: &DmChannel) -> Markup {
}
div class="space-y-1" {
p class="text-sm text-neutral-500" {
(channel.recipients.len()) " recipients \u{00b7} Status: "
(count_noun(channel.recipients.len() as u64, "recipient", "recipients")) " \u{00b7} Status: "
@if channel.is_open { "Open" } @else { "Closed" }
}
@if let Some(o) = owner {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -63,7 +64,7 @@ fn guild_row(config: &AdminConfig, user_id: &str, guild: &GuildInfo) -> Markup {
}
}
span class="text-xs text-neutral-500" {
(guild.member_count) " members"
(count_noun(guild.member_count, "member", "members"))
}
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::{count_noun, noun_for};
use crate::{
acl,
api::{
@@ -359,13 +360,13 @@ fn deletion_card(
div class="space-y-3" {
(form_label("Private Reason"))
input type="text" name="private_reason" required
placeholder="Why this deletion is being cancelled (audit log)..."
placeholder="Why this deletion is being canceled (audit log)..."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
@@ -567,7 +568,7 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
"Status: " (message_shred_status_label(status_value))
}
p class="text-sm text-neutral-700" {
"Requested " (requested) " entries, skipped " (skipped) " entries"
"Requested " (count_noun(requested, "entry", "entries")) ", skipped " (count_noun(skipped, "entry", "entries"))
}
@if status_value == "in_progress" {
div class="space-y-2" {
@@ -582,17 +583,17 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
}
@if status_value == "completed" {
p class="text-sm text-neutral-700" {
"Deleted " (processed) " / " (total) " entries"
"Deleted " (processed) " / " (total) " " (noun_for(total, "entry", "entries"))
}
}
@if let Some(started_at) = value_str(status, "started_at") {
p class="text-xs text-neutral-500" { "Started " (started_at) }
p class="text-xs text-neutral-500" { "Started " (format_admin_timestamp(started_at)) }
}
@if let Some(completed_at) = value_str(status, "completed_at") {
p class="text-xs text-neutral-500" { "Completed " (completed_at) }
p class="text-xs text-neutral-500" { "Completed " (format_admin_timestamp(completed_at)) }
}
@if let Some(failed_at) = value_str(status, "failed_at") {
p class="text-xs text-red-600" { "Failed " (failed_at) }
p class="text-xs text-red-600" { "Failed " (format_admin_timestamp(failed_at)) }
}
@if let Some(error) = value_str(status, "error") {
p class="text-sm text-red-600" { (error) }
@@ -66,13 +66,13 @@ fn render_overview_tab(
div class="space-y-6" {
@if let Some(until) = &user.temp_banned_until {
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
"Temporarily banned until " (until)
"Temporarily banned until " (format_admin_timestamp(until))
}
}
@if user.temp_banned_until.is_none() {
@if let Some(pending) = &user.pending_deletion_at {
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
div class="font-medium" { "Scheduled for deletion: " (pending) }
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
@if let Some(code) = user.deletion_reason_code {
div class="mt-1" { "Reason code: " (code) }
}
@@ -101,7 +101,7 @@ fn render_overview_tab(
@if let Some(pending) = &user.pending_bulk_message_deletion_at {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="font-medium text-neutral-700 text-sm" {
"Bulk message deletion scheduled for " (pending)
"Bulk message deletion scheduled for " (format_admin_timestamp(pending))
}
@if acl::has_permission(admin_acls, acl::USER_CANCEL_BULK_MESSAGE_DELETION) {
form method="post"
@@ -197,10 +197,10 @@ fn render_overview_tab(
}
}))
@if let Some(ref value) = user.premium_grace_ends_at {
(detail_row("Grace Period Ends", html! { (value) }))
(detail_row("Grace Period Ends", html! { (format_admin_timestamp(value)) }))
}
(detail_row("Last Active", html! {
(user.last_active_at.as_deref().unwrap_or("Never"))
(user.last_active_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_IP) {
(detail_row("Last IP", html! {
@@ -239,7 +239,7 @@ fn render_overview_tab(
div class="py-3 text-sm" {
dt class="font-medium text-neutral-900" {
(entry.field)
span class="ml-2 font-normal text-xs text-neutral-500" { (entry.event_at) }
span class="ml-2 font-normal text-xs text-neutral-500" { (format_admin_timestamp(&entry.event_at)) }
}
dd class="mt-1 font-mono text-xs text-neutral-700" {
(entry.old_value.as_deref().unwrap_or("null"))
@@ -599,6 +599,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -644,4 +645,23 @@ mod tests {
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
#[test]
fn last_active_and_grace_end_use_the_panel_date_format() {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"last_active_at": "2026-10-06T20:23:38.591Z",
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
}))
.expect("valid admin user");
let html =
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
.into_string();
assert!(!html.contains("2026-10-06T20:23"));
assert!(!html.contains("2026-11-01T08:00"));
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{ListUserRelationshipsResponse, RelationshipEntry},
config::AdminConfig,
@@ -176,7 +177,7 @@ fn relationship_row(
}
@if let Some(ref since) = entry.since {
p class="text-xs text-neutral-500" {
"Since: " (since)
"Since: " (format_admin_timestamp(since))
}
}
}
@@ -8,7 +8,7 @@ use crate::{
page_container::card_with_header,
report_category::{reason_repeats_category, report_category},
report_webhook::{reported_webhook, webhook_identity},
table::data_table,
table::{table, table_body, table_container, table_head},
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
@@ -108,7 +108,7 @@ fn report_section(
} else {
vec!["Reported At", "Type / Category", "Reporter", "Status"]
};
(data_table(
(report_tab_table(
&headers,
html! {
@for report in reports {
@@ -196,19 +196,52 @@ pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
}
}
pub(crate) fn report_tab_table(headers: &[&str], rows: Markup) -> Markup {
let has_actions = headers.last() == Some(&"Actions");
html! {
(table_container(table(html! {
(table_head(html! {
tr {
@for (index, header) in headers.iter().enumerate() {
@let folded = index == 1 || (has_actions && *header == "Status");
th class={"px-2 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider xl:px-4" (if folded { " hidden whitespace-nowrap xl:table-cell" } else { " whitespace-nowrap" })} {
@if has_actions && index + 1 == headers.len() {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
}
}))
(table_body(rows))
})))
}
}
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
html! {
td class="px-3 py-3 text-sm text-neutral-900" {
td class="px-2 py-3 text-sm text-neutral-900 xl:px-3" {
div { (format_admin_timestamp(&report.reported_at)) }
a href={(base) "/reports/" (report.report_id)}
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
(report.report_id)
}
div class="mt-1 xl:hidden" data-report-type-compact=(report.report_id) {
(type_and_category_lines(report))
}
}
}
}
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
html! {
td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(type_and_category_lines(report))
}
}
}
fn type_and_category_lines(report: &ReportEntry) -> Markup {
let reason_label = report
.reason
.as_deref()
@@ -219,7 +252,6 @@ pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
.zip(reason_label)
.is_some_and(|(category, label)| reason_repeats_category(category, label));
html! {
td class="px-4 py-3 text-sm text-neutral-900" {
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
@@ -233,7 +265,6 @@ pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
}
}
}
}
fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry) -> Markup {
let webhook = if kind == "sent" {
@@ -270,7 +301,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(webhook) = &webhook {
a href=(webhook.reports_href) class="hover:underline" {
(webhook_identity(webhook))
@@ -287,7 +318,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
span { (entity_display) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
(format_status(report.status))
}
}
@@ -361,6 +392,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -476,12 +508,24 @@ mod tests {
let markup = report_row(&config, "/admin", "received", &report).into_string();
assert_eq!(
markup.matches("Harassment or bullying").count(),
1,
2,
"{markup}"
);
assert_eq!(
markup.matches(r#"data-report-reason="harassment""#).count(),
1,
2,
"{markup}"
);
assert!(
markup.contains(
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
),
"{markup}"
);
assert!(
markup.contains(
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
),
"{markup}"
);
assert!(
+1
View File
@@ -2,5 +2,6 @@
pub mod bigint;
pub mod forms;
pub mod plural;
pub mod timestamps;
pub mod user_tag;
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub fn count_noun(count: u64, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {plural}")
}
}
pub fn noun_for(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn count_noun_picks_the_singular_only_for_one() {
assert_eq!(count_noun(0, "result", "results"), "0 results");
assert_eq!(count_noun(1, "result", "results"), "1 result");
assert_eq!(count_noun(2, "entry", "entries"), "2 entries");
assert_eq!(noun_for(1, "item", "items"), "item");
assert_eq!(noun_for(3, "item", "items"), "items");
}
}
+23 -49
View File
@@ -10,22 +10,6 @@ pub fn snowflake_to_timestamp_ms(snowflake: &str) -> Option<u64> {
Some(timestamp_ms)
}
pub fn format_unix_timestamp(unix_seconds: u64) -> String {
let offset = time::OffsetDateTime::from_unix_timestamp(unix_seconds as i64).ok();
match offset {
Some(dt) => {
let format = time::format_description::well_known::Rfc3339;
dt.format(&format)
.unwrap_or_else(|_| unix_seconds.to_string())
}
None => unix_seconds.to_string(),
}
}
pub fn format_unix_timestamp_ms(unix_ms: u64) -> String {
format_unix_timestamp(unix_ms / 1000)
}
pub fn relative_time(unix_seconds: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -60,16 +44,17 @@ pub fn relative_time(unix_seconds: u64) -> String {
}
pub fn format_admin_timestamp(iso: &str) -> String {
let dt = time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339)
time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339)
.or_else(|_| {
time::OffsetDateTime::parse(
iso,
&time::format_description::well_known::Iso8601::DEFAULT,
)
})
.ok();
match dt {
Some(dt) => {
.map_or_else(|_| iso.to_owned(), format_admin_datetime)
}
fn format_admin_datetime(dt: time::OffsetDateTime) -> String {
let dt = dt.to_offset(time::UtcOffset::UTC);
let month = match dt.month() {
time::Month::January => "Jan",
@@ -96,15 +81,13 @@ pub fn format_admin_timestamp(iso: &str) -> String {
let ampm = if dt.hour() < 12 { "AM" } else { "PM" };
format!("{month} {day}, {year}, {hour_12}:{minute:02} {ampm} UTC")
}
None => iso.to_owned(),
}
}
pub fn snowflake_creation_date(snowflake: &str) -> String {
match snowflake_to_timestamp_ms(snowflake) {
Some(ms) => format_unix_timestamp_ms(ms),
None => "Unknown".to_owned(),
}
snowflake_to_timestamp_ms(snowflake)
.and_then(|ms| {
time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000).ok()
})
.map_or_else(|| "Unknown".to_owned(), format_admin_datetime)
}
#[cfg(test)]
@@ -132,24 +115,6 @@ mod tests {
assert_eq!(snowflake_to_timestamp_ms("-1"), None);
}
#[test]
fn format_unix_timestamp_iso8601() {
let result = format_unix_timestamp(1_704_067_200);
assert!(
result.starts_with("2024-01-01"),
"expected ISO date starting with 2024-01-01, got: {result}"
);
}
#[test]
fn format_unix_timestamp_ms_converts() {
let result = format_unix_timestamp_ms(1_704_067_200_000);
assert!(
result.starts_with("2024-01-01"),
"expected ISO date, got: {result}"
);
}
#[test]
fn relative_time_just_now() {
let now = SystemTime::now()
@@ -196,10 +161,19 @@ mod tests {
#[test]
fn snowflake_creation_date_valid() {
let result = snowflake_creation_date("0");
assert!(
result.starts_with("2015-01-01"),
"expected 2015-01-01, got: {result}"
assert_eq!(snowflake_creation_date("0"), "Jan 1, 2015, 12:00 AM UTC");
}
#[test]
fn admin_timestamps_render_in_the_panel_format() {
assert_eq!(
format_admin_timestamp("2026-10-06T14:05:09.123Z"),
"Oct 6, 2026, 2:05 PM UTC"
);
assert_eq!(
format_admin_timestamp("2026-10-06T00:30:00+02:00"),
"Oct 5, 2026, 10:30 PM UTC"
);
assert_eq!(format_admin_timestamp("not a date"), "not a date");
}
}
+1
View File
@@ -93,6 +93,7 @@ fn production_config(api_endpoint: String) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: ADMIN_ORIGIN.to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
+115 -2
View File
@@ -1079,7 +1079,7 @@ async fn redirect_flash_actions_convert_to_htmx_toasts() {
&format!("{}; csrf_token={}", app.session_cookie, csrf_token),
),
],
&format!("_csrf={csrf_token}&resolution=done"),
&format!("_csrf={csrf_token}&resolution=no_violation&public_comment=done"),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}");
@@ -1094,7 +1094,101 @@ async fn redirect_flash_actions_convert_to_htmx_toasts() {
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
assert!(toast.contains("success"), "{toast}");
assert!(toast.contains("Report resolved"), "{toast}");
assert!(toast.contains("Report resolved: No violation"), "{toast}");
}
#[tokio::test]
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/reports/1800000000000000001",
routing::patch(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body);
json_response(json!({
"report_id": "1800000000000000001",
"status": 1,
"resolved_at": "2026-05-26T12:03:00.000Z",
"public_comment": null
}))
}
})
.get(|| async { json_response(searched_report()) }),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(&page, "/reports/1800000000000000001/resolve", &csrf_token);
assert!(
page.contains(r#"<select id="resolution" name="resolution" required"#),
"{page}"
);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let post = |body: String| {
let app = &app;
let cookie = cookie.clone();
async move {
let (status, headers, text) = post_form_with_headers(
app,
"/reports/1800000000000000001/resolve",
&[
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", &cookie),
],
&body,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
.to_owned()
}
};
for (value, label) in [
("actioned", "Action taken"),
("no_violation", "No violation"),
("duplicate", "Duplicate"),
] {
let toast = post(format!("_csrf={csrf_token}&resolution={value}")).await;
assert!(toast.contains("success"), "{toast}");
assert!(
toast.contains(&format!("Report resolved: {label}")),
"{toast}"
);
}
for refused in [
format!("_csrf={csrf_token}&public_comment=Looks+fine"),
format!("_csrf={csrf_token}&resolution=&public_comment=Looks+fine"),
format!("_csrf={csrf_token}&resolution=auto_resolved"),
] {
let toast = post(refused).await;
assert!(toast.contains("error"), "{toast}");
assert!(toast.contains("Choose a resolution"), "{toast}");
}
let toast = post(format!(
"_csrf={csrf_token}&resolution=actioned&public_comment=Removed&notify_reporter_present=1"
))
.await;
assert!(toast.contains("Report resolved: Action taken"), "{toast}");
let received = received.lock().unwrap();
assert_eq!(
*received,
vec![
json!({"status": "resolved", "resolution": "actioned", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "no_violation", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "duplicate", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "actioned", "public_comment": "Removed", "notify_reporter": false}),
]
);
}
#[tokio::test]
@@ -1371,6 +1465,24 @@ async fn rendered_heads_never_reference_the_static_cdn_for_fonts() {
);
}
#[tokio::test]
async fn the_csp_allows_images_from_the_configured_reports_bucket() {
let app = setup().await;
let (headers, _) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
let csp = headers
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|value| value.to_str().ok())
.expect("missing CSP");
assert!(
csp.contains(
"img-src 'self' data: blob: https://static.example.test https://media.example.test \
https://reports.example.test;"
),
"img-src was {csp}"
);
assert!(!csp.contains("vultrobjects"), "{csp}");
}
struct SearchCase {
path: &'static str,
result_target: &'static str,
@@ -2282,6 +2394,7 @@ fn test_config(api_endpoint: String) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
+9 -3
View File
@@ -131,15 +131,20 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
let uri = format!("/reports/{REPORT_ID}/resolve");
let checked = submit(
&uri,
"resolution=Handled&notify_reporter_present=1&notify_reporter=true",
"resolution=actioned&public_comment=Handled&notify_reporter_present=1&notify_reporter=true",
)
.await;
assert_eq!(checked.route, format!("PATCH /admin/reports/{REPORT_ID}"));
assert_eq!(checked.body["public_comment"], json!("Handled"));
assert_eq!(checked.body["resolution"], json!("actioned"));
assert_eq!(checked.body["notify_reporter"], json!(true));
let unchecked = submit(&uri, "resolution=Handled&notify_reporter_present=1").await;
let unchecked = submit(
&uri,
"resolution=no_violation&public_comment=Handled&notify_reporter_present=1",
)
.await;
assert_eq!(unchecked.body["notify_reporter"], json!(false));
let stale_form = submit(&uri, "resolution=Handled").await;
let stale_form = submit(&uri, "resolution=duplicate&public_comment=Handled").await;
assert_eq!(stale_form.body["notify_reporter"], json!(true));
}
@@ -349,6 +354,7 @@ fn test_config(api_endpoint: String) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
+2
View File
@@ -88,6 +88,7 @@ fn guild_search_fixture_matches_the_generated_response_contract() {
assert_eq!(response.guilds.len(), 1);
let guild = &response.guilds[0];
assert_eq!(guild.name, "Parity Guild");
assert_eq!(guild.nsfw, Some(false));
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
}
@@ -100,6 +101,7 @@ fn guild_lookup_fixture_matches_the_generated_response_contract() {
.guild
.expect("guild lookup fixture must contain a guild");
assert_eq!(String::from(guild.name), "Parity Guild");
assert_eq!(guild.nsfw, Some(false));
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
assert_eq!(guild.channels.len(), 1);
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
+1
View File
@@ -53,6 +53,7 @@ fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminCon
api_endpoint: api_endpoint.to_owned(),
media_endpoint: format!("{api_endpoint}/media"),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
oauth_client_id: "1234567890123456789".to_owned(),
@@ -431,6 +431,7 @@ fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: String::new(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
+1
View File
@@ -226,6 +226,7 @@ fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: String::new(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
@@ -321,6 +321,7 @@ fn test_config(api_endpoint: String) -> AdminConfig {
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
@@ -31,6 +31,9 @@ export function mapGuildToAdminResponse(guild: Guild, ownerUser?: User | null):
banner: guild.bannerHash,
member_count: guild.memberCount,
nsfw_level: guild.nsfwLevel,
nsfw: guild.nsfw,
content_warning_level: guild.contentWarningLevel as 0 | 1,
content_warning_text: guild.contentWarningText,
};
}
@@ -48,6 +51,9 @@ export function mapGuildsToAdminResponse(guilds: Array<Guild>, ownerMap?: Map<st
banner: guild.bannerHash,
member_count: guild.memberCount,
nsfw_level: guild.nsfwLevel,
nsfw: guild.nsfw,
content_warning_level: guild.contentWarningLevel as 0 | 1,
content_warning_text: guild.contentWarningText,
};
}),
};
@@ -60,6 +60,9 @@ export class AdminGuildLookupService {
verification_level: guild.verificationLevel,
mfa_level: guild.mfaLevel,
nsfw_level: guild.nsfwLevel,
nsfw: guild.nsfw,
content_warning_level: guild.contentWarningLevel as 0 | 1,
content_warning_text: guild.contentWarningText,
explicit_content_filter: guild.explicitContentFilter,
default_message_notifications: guild.defaultMessageNotifications,
afk_channel_id: guild.afkChannelId?.toString() ?? null,
@@ -76,6 +79,9 @@ export class AdminGuildLookupService {
position: c.position,
parent_id: c.parentId?.toString() ?? null,
nsfw: c.isNsfw,
nsfw_override: c.nsfwOverride,
content_warning_level: c.contentWarningLevel as 0 | 1,
content_warning_text: c.contentWarningText,
url: c.url,
})),
roles: roles.map((r) => ({
@@ -16,6 +16,19 @@ interface AdminGuildDetail {
} | null;
}
interface AdminGuildWarningDetail {
guild: {
nsfw: boolean;
content_warning_level: number;
content_warning_text: string | null;
channels: Array<{
nsfw_override: boolean | null;
content_warning_level: number;
content_warning_text: string | null;
}>;
} | null;
}
interface AdminGuildUpdate {
guild: {
id: string;
@@ -98,6 +111,30 @@ describe('Admin guild routes', () => {
'owner_id',
]);
});
test('GET /admin/guilds/{guild_id} returns the adult content and content warning state', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup', 'guild:update:settings']);
const guild = await createGuild(harness, admin.token, `Warning Lookup Guild ${Date.now()}`);
const before = await createBuilder<AdminGuildWarningDetail>(harness, `${admin.token}`)
.get(`/admin/guilds/${guild.id}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(before.guild).toMatchObject({nsfw: false, content_warning_level: 0, content_warning_text: null});
expect(before.guild?.channels.length).toBeGreaterThan(0);
for (const channel of before.guild?.channels ?? []) {
expect(channel).toMatchObject({nsfw_override: null, content_warning_level: 0, content_warning_text: null});
}
await createBuilder(harness, `${admin.token}`)
.patch(`/admin/guilds/${guild.id}`)
.body({nsfw: true, content_warning_level: 1, content_warning_text: 'Graphic content'})
.expect(HTTP_STATUS.OK)
.execute();
const after = await createBuilder<AdminGuildWarningDetail>(harness, `${admin.token}`)
.get(`/admin/guilds/${guild.id}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(after.guild).toMatchObject({nsfw: true, content_warning_level: 1, content_warning_text: 'Graphic content'});
});
test('PATCH /admin/guilds/{guild_id} requires the ACL selected by every supplied field', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:settings']);
@@ -223,6 +223,36 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
...(['actioned', 'no_violation', 'duplicate'] as const).map(
(resolution): AdminAuditCoverageCase => ({
method: 'PATCH',
route: '/admin/reports/:report_id',
name: `resolution ${resolution}`,
async prepare(context) {
const {reportId} = await fileUserReport(context);
return {
request: {
path: `/admin/reports/${reportId}`,
body: {status: 'resolved', resolution, notify_reporter: false},
},
expected: {
action: 'resolve_report',
targetType: 'report',
targetId: reportId,
metadata: {
report_id: reportId,
report_type: '1',
notify_reporter: 'false',
reporter_dm_sent: 'true',
reporter_email_sent: 'true',
reporter_dsa_email_sent: 'false',
resolution,
},
},
};
},
}),
),
{
method: 'POST',
route: '/admin/reports/:report_id/legal-hold',
@@ -146,6 +146,22 @@ describe('moderation activity events', () => {
expect(resolved?.meta.ip).toBeNull();
});
test.each(['actioned', 'no_violation', 'duplicate'] as const)(
'a staff resolve with %s publishes that outcome even when the account is under enforcement',
async (resolution) => {
const {reported, reportId} = await fileReport();
await createBuilder(harness, admin.token)
.put(`/admin/users/${reported.userId}/ban`)
.body({duration_hours: 24, notify_user: false})
.expect(HTTP_STATUS.OK)
.execute();
await resolve(reportId, {resolution});
expect(publisher.of('report_resolved').map((event) => event.data)).toEqual([
expect.objectContaining({report_id: reportId, outcome: resolution, resolved_by: 'staff'}),
]);
},
);
test('without a chosen outcome the reported account state decides between actioned and unspecified', async () => {
const first = await fileReport();
await resolve(first.reportId, {});
@@ -35,19 +35,17 @@ The compact guild representation returned by [List guilds](#list-guilds) and by
| banner | ?string | The banner hash, or null when unset |
| member_count<sup>2</sup> | integer | The recorded member count |
| nsfw_level? | integer | The [NSFW level](/http-api/guilds/#nsfw-levels) |
| nsfw?<sup>3</sup> | boolean | Whether the guild is flagged as adult content |
| content_warning_level?<sup>3</sup> | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
| content_warning_text?<sup>3</sup> | ?string | The custom content warning text |
| approximate_member_count?<sup>4</sup> | integer | The member count the main Gateway reports |
| approximate_presence_count?<sup>4</sup> | integer | The connected member count the main Gateway reports |
| nsfw | boolean | Whether the guild is flagged as adult content |
| content_warning_level | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
| content_warning_text | ?string | The custom content warning text, or null when unset |
| approximate_member_count?<sup>3</sup> | integer | The member count the main Gateway reports |
| approximate_presence_count?<sup>3</sup> | integer | The connected member count the main Gateway reports |
<sup>1</sup> All are null whenever the owner account is not resolved. [List guilds](#list-guilds) never resolves it, so all are always null there
<sup>2</sup> A member connecting or disconnecting does not change it
<sup>3</sup> No operation populates these, so they are absent from every current response
<sup>4</sup> Present only on [List user guilds](/admin-api/users/#list-user-guilds) when the request sets `with_counts` to true
<sup>3</sup> Present only on [List user guilds](/admin-api/users/#list-user-guilds) when the request sets `with_counts` to true
### Example
@@ -63,7 +61,10 @@ The compact guild representation returned by [List guilds](#list-guilds) and by
"icon": "b7f1c2d3e4a5968778695a4b3c2d1e0f",
"banner": null,
"member_count": 4182,
"nsfw_level": 0
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": 0,
"content_warning_text": null
}
```
@@ -90,9 +91,9 @@ The full guild representation returned by [Get guild](#get-guild). It embeds eve
| verification_level | integer | The [verification level](/http-api/guilds/#verification-levels) |
| mfa_level | integer | The [MFA level](/http-api/guilds/#mfa-levels) required of moderators |
| nsfw_level | integer | The [NSFW level](/http-api/guilds/#nsfw-levels) |
| nsfw?<sup>2</sup> | boolean | Whether the guild is flagged as adult content |
| content_warning_level?<sup>2</sup> | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
| content_warning_text?<sup>2</sup> | ?string | The custom content warning text |
| nsfw | boolean | Whether the guild is flagged as adult content |
| content_warning_level | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
| content_warning_text | ?string | The custom content warning text, or null when unset |
| explicit_content_filter | integer | The [explicit content filter level](/http-api/guilds/#guild-explicit-content-filter-levels) |
| default_message_notifications | integer | The [default message notification level](/http-api/guilds/#default-message-notification-levels) |
| afk_channel_id | ?snowflake | The voice channel idle members are moved to, or null when unset |
@@ -107,8 +108,6 @@ The full guild representation returned by [Get guild](#get-guild). It embeds eve
<sup>1</sup> All are null when the owner account cannot be resolved
<sup>2</sup> The operation does not populate these, so they are absent from every current response
### Admin guild channel object
#### Structure
@@ -121,14 +120,12 @@ The full guild representation returned by [Get guild](#get-guild). It embeds eve
| position | integer | The sort position within the guild |
| parent_id | ?snowflake | The parent category ID, or null when the channel is top-level |
| nsfw | ?boolean | Whether the channel is marked age restricted |
| nsfw_override?<sup>3</sup> | ?boolean | The age restriction set on the channel itself |
| content_warning_level?<sup>3</sup> | integer | The [content warning level](/http-api/guilds/#guild-content-warning-levels) set on the channel |
| content_warning_text?<sup>3</sup> | ?string | The content warning text set on the channel |
| url<sup>4</sup> | ?string | The external channel URL (1-2048 characters) |
| nsfw_override | ?boolean | The age restriction set on the channel itself, or null when the channel sets none |
| content_warning_level | integer | The [content warning level](/http-api/guilds/#guild-content-warning-levels) set on the channel |
| content_warning_text | ?string | The content warning text set on the channel, or null when unset |
| url<sup>2</sup> | ?string | The external channel URL (1-2048 characters) |
<sup>3</sup> The operation does not populate these, so they are absent from every current response
<sup>4</sup> Null for every channel type that is not an external link channel
<sup>2</sup> Null for every channel type that is not an external link channel
### Admin guild role object
@@ -343,12 +340,12 @@ A body with no field at all selects nothing, so an empty patch applies no change
| verification_level? | integer | The replacement [verification level](/http-api/guilds/#verification-levels) |
| mfa_level? | integer | The replacement [MFA level](/http-api/guilds/#mfa-levels) |
| nsfw_level? | integer | The replacement [NSFW level](/http-api/guilds/#nsfw-levels) |
| nsfw?<sup>5</sup> | boolean | Accepted and ignored |
| content_warning_level?<sup>5</sup> | integer | Accepted and ignored |
| content_warning_text?<sup>5</sup> | ?string | Accepted and ignored (at most 200 characters) |
| nsfw? | boolean | Whether the guild is flagged as adult content |
| content_warning_level? | integer | The replacement [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
| content_warning_text? | ?string | The replacement custom content warning text (at most 200 characters), or null to clear it |
| explicit_content_filter? | integer | The replacement [explicit content filter level](/http-api/guilds/#guild-explicit-content-filter-levels) |
| default_message_notifications? | integer | The replacement [default message notification level](/http-api/guilds/#default-message-notification-levels) |
| disabled_operations?<sup>6</sup> | integer | The replacement [disabled guild operations](/http-api/guilds/#disabled-guild-operations) bitfield |
| disabled_operations?<sup>5</sup> | integer | The replacement [disabled guild operations](/http-api/guilds/#disabled-guild-operations) bitfield |
<sup>1</sup> Normalised to lowercase with whitespace folded to hyphens and consecutive hyphens collapsed. The normalised result is 2 to 32 characters, and a shorter or longer one is rejected with 400
@@ -358,9 +355,7 @@ A body with no field at all selects nothing, so an empty patch applies no change
<sup>4</sup> The array only clears an image, and no Admin operation uploads one
<sup>5</sup> Validated, selects `guild:update:settings`, and records an `update_settings` audit entry. The guild record is left unchanged
<sup>6</sup> Any integer from 0 to 2147483647, stored as supplied, including bits outside the documented registry
<sup>5</sup> Any integer from 0 to 2147483647, stored as supplied, including bits outside the documented registry
A code already claimed by any invite is rejected with 400 `INVALID_FORM_BODY` and the validation code `THIS_VANITY_URL_IS_ALREADY_TAKEN` against `vanity_url_code`.
@@ -680,6 +680,8 @@ Resolves one report and returns a [report resolution](#report-resolution-object)
<sup>3</sup> Optional, so older admin clients that send none still work. It is not stored on the report, not returned by any report route, and not shown to the reporter. It is recorded in the audit entry
The admin panel always sends `resolution`. Its resolve form has a required Resolution select with three choices: Action taken (`actioned`), No violation (`no_violation`), and Duplicate (`duplicate`). The browser refuses to submit the form without a choice, and the panel checks again on the server: a submission with no choice, or with any other value, is refused with the error "Choose a resolution" and the report stays pending. After a resolve, the panel confirms the choice in its message, such as "Report resolved: No violation". The report page itself does not show the choice, because no report route returns it. Staff find it in the `resolution` metadata of the `resolve_report` entry in the audit log.
:::caution[Resolution is terminal]
Resolving a report notifies the reporter whatever `notify_reporter` says, as the side effects below describe. A resolved report never returns to PENDING, and a second update against it returns 400 `REPORT_ALREADY_RESOLVED` without changing the stored public comment.
:::
@@ -76,6 +76,7 @@ On an instance whose `tag_style` [feature](/http-api/instance/#instance-features
| 1 &lt;&lt; 4 | FRIENDLY_BOT | Bot accepts friend requests from users |
| 1 &lt;&lt; 5 | FRIENDLY_BOT_MANUAL_APPROVAL | Bot requires manual approval for friend requests |
| 1 &lt;&lt; 6 | SPAMMER | Account is flagged as a spammer |
| 1 &lt;&lt; 7 | PROFILE_HIDDEN | Profile details are hidden from other users |
## Reply mention preferences