diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 7d0295353..faacb9163 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -202,11 +202,6 @@ services: target: /workspaces/fluxer/fluxer_api/pkgs/rate_limit/node_modules volume: nocopy: true - - type: volume - source: fluxer-api-sms-node-modules - target: /workspaces/fluxer/fluxer_api/pkgs/sms/node_modules - volume: - nocopy: true - type: volume source: fluxer-api-virus-scan-node-modules target: /workspaces/fluxer/fluxer_api/pkgs/virus_scan/node_modules @@ -384,7 +379,6 @@ volumes: fluxer-api-mime-utils-node-modules: fluxer-api-nats-node-modules: fluxer-api-rate-limit-node-modules: - fluxer-api-sms-node-modules: fluxer-api-virus-scan-node-modules: fluxer-api-worker-node-modules: fluxer-app-list-utils-node-modules: diff --git a/config/env/development.env b/config/env/development.env index f87207d6d..f8b6b027b 100644 --- a/config/env/development.env +++ b/config/env/development.env @@ -107,7 +107,6 @@ FLUXER_EMAIL_SMTP_PORT=1025 FLUXER_EMAIL_SMTP_USERNAME=dev FLUXER_EMAIL_SMTP_PASSWORD=dev FLUXER_EMAIL_SMTP_SECURE=false -FLUXER_SMS_ENABLED=false FLUXER_CAPTCHA_ENABLED=false FLUXER_CAPTCHA_PROVIDER=none FLUXER_SEARCH_ENGINE=meilisearch diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index 97b6f09d9..a87dc1fca 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -98,15 +98,12 @@ MEILI_MASTER_KEY=CHANGE_ME #FLUXER_LIVEKIT_ENABLED=false # Optional systems, each off unless configured. -#FLUXER_SMS_ENABLED=false #FLUXER_STRIPE_ENABLED=false #FLUXER_NCMEC_ENABLED=false #FLUXER_CLAMAV_ENABLED=false -# Outside lookups, off unless turned on. The Tor exit list comes from -# onionoo.torproject.org and the breached password check asks +# Outside lookups, off unless turned on. The breached password check asks # api.pwnedpasswords.com. -#FLUXER_TOR_EXIT_LIST_ENABLED=true #FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true # The client address. Name the header your proxy actually writes, and turn the diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index 01a043ad9..20472a9c9 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -24,7 +24,6 @@ x-fluxer-env: &fluxer-env FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for} FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000} FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000} - FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}" FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}" FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0} @@ -74,7 +73,6 @@ x-fluxer-env: &fluxer-env FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-} FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true} - FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}" FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false} FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none} FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index b7886cc0d..a1a3f63c2 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -6070,6 +6070,69 @@ ] } }, + "/admin/users/{user_id}/ban/notes": { + "post": { + "operationId": "annotate_admin_user_ban", + "summary": "Add a note to a user ban", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Append a note to the current ban of a user. The note is recorded as the reason of a new annotate_ban audit log entry whose metadata names the ban audit log entry. Earlier entries are never changed. Requires USER_TEMP_BAN permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBanNoteRequest"}}} + } + } + }, "/admin/users/{user_id}/bot-status": { "put": { "operationId": "set_admin_user_bot_status", @@ -6329,7 +6392,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.", + "description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6393,7 +6456,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.", + "description": "Cancel the scheduled account deletion named by expected_pending_deletion_at. Returns 409 when a different deletion is pending and 400 when none is. The user is emailed only when notify_user is true, and the email never includes the audit log reason. Creates audit log entry recording the cancelled deletion. Requires USER_DELETE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6403,7 +6466,11 @@ "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, "description": "The ID of the user" } - ] + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserDeletionCancelRequest"}}} + } } }, "/admin/users/{user_id}/dm-channels": { @@ -9702,6 +9769,23 @@ } ] }, + "AdminUserDeletionCancelRequest": { + "type": "object", + "properties": { + "expected_pending_deletion_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "pending_deletion_at of the deletion being cancelled, as shown on the account" + }, + "notify_user": { + "default": false, + "description": "Whether to email the user that the deletion was cancelled", + "type": "boolean" + } + }, + "required": ["expected_pending_deletion_at"] + }, "AdminUserDeletionScheduleRequest": { "type": "object", "properties": { @@ -9716,6 +9800,12 @@ "type": "integer", "minimum": 1, "maximum": 365 + }, + "replace_pending_deletion_at": { + "description": "pending_deletion_at of the deletion this request replaces. Required when a deletion is already scheduled for the account", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" } }, "required": ["reason_code"] @@ -9756,6 +9846,20 @@ "properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}}, "required": ["bot"] }, + "AdminUserBanNoteRequest": { + "type": "object", + "properties": { + "ban_audit_log_id": { + "description": "Audit log entry of the current ban that the note refers to", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "note": { + "description": "Note to append to the ban. Recorded as the reason of a new audit log entry", + "type": "string" + } + }, + "required": ["ban_audit_log_id", "note"] + }, "AdminUserBanRequest": { "type": "object", "properties": { @@ -9837,7 +9941,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions to assign" @@ -10719,16 +10823,6 @@ }, "required": ["gif", "youtube", "bluesky"], "additionalProperties": false - }, - "deferred_phone_gate": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "window_hours": {"type": "number"}, - "member_threshold": {"type": "number"} - }, - "required": ["enabled", "window_hours", "member_threshold"], - "additionalProperties": false } }, "required": [ @@ -10739,8 +10833,7 @@ "premium_mode", "services", "services_resolved", - "services_available", - "deferred_phone_gate" + "services_available" ], "additionalProperties": false }, @@ -11312,15 +11405,6 @@ "youtube_enabled": {"nullable": true, "type": "boolean"}, "bluesky_enabled": {"nullable": true, "type": "boolean"} } - }, - "deferred_phone_gate": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "window_hours": {"type": "number", "minimum": 0, "exclusiveMinimum": true, "maximum": 8760}, - "member_threshold": {"type": "integer", "minimum": 0, "exclusiveMinimum": true, "maximum": 1000000} - } } } }, @@ -12323,17 +12407,7 @@ }, "AdminBlocklistListType": { "type": "string", - "enum": [ - "ip", - "email", - "email-domain-suspicious", - "phrase", - "url", - "url-domain", - "file-sha", - "avatar-hash", - "profile-substring" - ], + "enum": ["ip", "email", "phrase", "url", "url-domain", "file-sha", "avatar-hash", "profile-substring"], "description": "The blocklist an entry belongs to" }, "AdminBlocklistEntryUpdateRequest": { @@ -12376,7 +12450,6 @@ "anyOf": [ {"$ref": "#/components/schemas/BanIpRequest"}, {"$ref": "#/components/schemas/BanEmailRequest"}, - {"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"}, {"$ref": "#/components/schemas/BanPhraseRequest"}, {"$ref": "#/components/schemas/BanUrlRequest"}, {"$ref": "#/components/schemas/BanUrlDomainRequest"}, @@ -12753,7 +12826,7 @@ }, "acls": { "description": "Replacement list of access control permissions for the key", - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"} } @@ -12771,7 +12844,7 @@ "type": "string" }, "acls": { - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -12801,7 +12874,7 @@ "maximum": 365 }, "acls": { - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions for the key" @@ -12822,7 +12895,7 @@ "type": "string" }, "acls": { - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -12835,7 +12908,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 111, + "maxItems": 108, "type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 64}, "description": "Every admin access control permission the admin API recognises" @@ -12864,9 +12937,6 @@ "ban:email:add", "ban:email:check", "ban:email:remove", - "suspicious_email_domain:add", - "suspicious_email_domain:check", - "suspicious_email_domain:remove", "ban:phrase:add", "ban:phrase:check", "ban:phrase:remove", @@ -13179,19 +13249,6 @@ }, "required": ["phrase"] }, - "SuspiciousEmailDomainRequest": { - "type": "object", - "properties": { - "domain": { - "type": "string", - "minLength": 1, - "maxLength": 253, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}$", - "description": "Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number." - } - }, - "required": ["domain"] - }, "BanEmailRequest": { "type": "object", "properties": { @@ -13434,12 +13491,12 @@ { "name": "FORCE_INBOUND_PHONE_VERIFICATION", "value": "2305843009213693952", - "description": "User is forced through inbound (expensive-destination) phone verification regardless of phone prefix, for debugging" + "description": "User is forced through inbound phone verification, for debugging" }, { "name": "NOT_SUSPICIOUS", "value": "4611686018427387904", - "description": "User is permanently exempt from automatic suspicious-activity flagging on RPC session start (does not require a prior payment)" + "description": "User is permanently exempt from automatic suspicious-activity flagging" } ] }, @@ -15703,14 +15760,29 @@ "suspicious_activity_flags": {"allOf": [{"$ref": "#/components/schemas/SuspiciousActivityFlags"}]}, "phone_verification_deferred": { "type": "boolean", - "description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community" + "description": "Whether a stored phone requirement is deferred and not enforced" }, "temp_banned_until": {"nullable": true, "type": "string"}, "pending_deletion_at": {"nullable": true, "type": "string"}, "pending_bulk_message_deletion_at": {"nullable": true, "type": "string"}, "deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, "deletion_public_reason": {"nullable": true, "type": "string"}, - "acls": {"maxItems": 111, "type": "array", "items": {"type": "string"}}, + "deletion_audit_log_reason": { + "nullable": true, + "description": "Private reason recorded with the pending deletion, null without the audit log view permission", + "type": "string" + }, + "deletion_scheduled_by": { + "nullable": true, + "description": "ID of the account that scheduled the pending deletion, null when it was not recorded", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "deletion_scheduled_at": { + "nullable": true, + "description": "ISO 8601 timestamp when the pending deletion was scheduled", + "type": "string" + }, + "acls": {"maxItems": 108, "type": "array", "items": {"type": "string"}}, "traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}}, "has_totp": {"type": "boolean"}, "authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}}, @@ -15751,6 +15823,9 @@ "pending_bulk_message_deletion_at", "deletion_reason_code", "deletion_public_reason", + "deletion_audit_log_reason", + "deletion_scheduled_by", + "deletion_scheduled_at", "acls", "traits", "has_totp", diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index 7c4fa0e05..d2b8bf3b1 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -17,9 +17,6 @@ pub const JOBS_CANCEL: &str = "jobs:cancel"; pub const BAN_EMAIL_ADD: &str = "ban:email:add"; pub const BAN_EMAIL_CHECK: &str = "ban:email:check"; pub const BAN_EMAIL_REMOVE: &str = "ban:email:remove"; -pub const SUSPICIOUS_EMAIL_DOMAIN_ADD: &str = "suspicious_email_domain:add"; -pub const SUSPICIOUS_EMAIL_DOMAIN_CHECK: &str = "suspicious_email_domain:check"; -pub const SUSPICIOUS_EMAIL_DOMAIN_REMOVE: &str = "suspicious_email_domain:remove"; pub const BAN_PHRASE_ADD: &str = "ban:phrase:add"; pub const BAN_PHRASE_CHECK: &str = "ban:phrase:check"; pub const BAN_PHRASE_REMOVE: &str = "ban:phrase:remove"; @@ -129,9 +126,6 @@ pub const ALL_ACLS: &[&str] = &[ BAN_EMAIL_ADD, BAN_EMAIL_CHECK, BAN_EMAIL_REMOVE, - SUSPICIOUS_EMAIL_DOMAIN_ADD, - SUSPICIOUS_EMAIL_DOMAIN_CHECK, - SUSPICIOUS_EMAIL_DOMAIN_REMOVE, BAN_PHRASE_ADD, BAN_PHRASE_CHECK, BAN_PHRASE_REMOVE, diff --git a/fluxer_admin/src/api/bans.rs b/fluxer_admin/src/api/bans.rs index cdd43fa20..86f345e95 100644 --- a/fluxer_admin/src/api/bans.rs +++ b/fluxer_admin/src/api/bans.rs @@ -9,12 +9,11 @@ impl AdminApiClient { pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "email", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_1: Some(generated_types::BanEmailRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanEmailRequest { email: generated_types::EmailType::from(email.to_owned()), - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -32,10 +31,9 @@ impl AdminApiClient { pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "ip", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }), - ..Default::default() - }, + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanIpRequest { ip: ip.to_owned() }, + ), audit_log_reason, ) .await @@ -50,45 +48,14 @@ impl AdminApiClient { self.check_blocklist_entry("ip", ip, None).await } - pub async fn add_suspicious_email_domain( - &self, - domain: &str, - audit_log_reason: Option<&str>, - ) -> ApiResult<()> { - self.create_blocklist_entry( - SUSPICIOUS_EMAIL_DOMAIN_LIST, - generated_types::AdminBlocklistEntryCreateRequest { - subtype_2: Some(suspicious_email_domain_request(domain)?), - ..Default::default() - }, - audit_log_reason, - ) - .await - } - - pub async fn remove_suspicious_email_domain( - &self, - domain: &str, - audit_log_reason: Option<&str>, - ) -> ApiResult<()> { - self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason) - .await - } - - pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult { - self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None) - .await - } - pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "phrase", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_3: Some(generated_types::BanPhraseRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanPhraseRequest { phrase: phrase.to_owned(), - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -110,16 +77,15 @@ impl AdminApiClient { pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "url", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_4: Some(generated_types::BanUrlRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanUrlRequest { category: None, notes: None, severity: None, source_url: None, url: url.to_owned(), - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -142,17 +108,16 @@ impl AdminApiClient { ) -> ApiResult<()> { self.create_blocklist_entry( "url-domain", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_5: Some(generated_types::BanUrlDomainRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanUrlDomainRequest { category: None, domain: domain.to_owned(), match_subdomains, notes: None, severity: None, source_url: None, - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -178,17 +143,16 @@ impl AdminApiClient { ) -> ApiResult<()> { self.create_blocklist_entry( "file-sha", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_6: Some(generated_types::BanFileShaRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanFileShaRequest { category: None, content_type: None, notes: None, severity: None, sha256_hex: sha256_hex.to_owned(), source_url: None, - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -231,17 +195,16 @@ impl AdminApiClient { ) -> ApiResult<()> { self.create_blocklist_entry( "avatar-hash", - generated_types::AdminBlocklistEntryCreateRequest { - subtype_7: Some(generated_types::BanAvatarHashRequest { + generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanAvatarHashRequest { category: None, hashes: vec![hash_short.to_owned()], notes: None, reason: None, severity: None, source_url: None, - }), - ..Default::default() - }, + }, + ), audit_log_reason, ) .await @@ -279,10 +242,9 @@ impl AdminApiClient { ) -> ApiResult<()> { self.create_blocklist_entry( PROFILE_SUBSTRING_LIST, - generated_types::AdminBlocklistEntryCreateRequest { - subtype_8: Some(profile_substring_request(scope, substring)?), - ..Default::default() - }, + generated_types::AdminBlocklistEntryCreateRequest::from(profile_substring_request( + scope, substring, + )?), audit_log_reason, ) .await @@ -359,8 +321,6 @@ impl AdminApiClient { } } -const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious"; - const PROFILE_SUBSTRING_LIST: &str = "profile-substring"; fn blocklist_list_type(list_type: &str) -> ApiResult { @@ -380,15 +340,6 @@ fn blocklist_delete_scope( .map_err(|e| ApiError::Parse(e.to_string())) } -fn suspicious_email_domain_request( - domain: &str, -) -> ApiResult { - Ok(generated_types::SuspiciousEmailDomainRequest { - domain: generated_types::SuspiciousEmailDomainRequestDomain::try_from(domain) - .map_err(|e| ApiError::Parse(e.to_string()))?, - }) -} - fn profile_substring_request( scope: &str, substring: &str, diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index c4357b36b..8fc478fa9 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -90,10 +90,7 @@ impl AdminApiClient { fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult { let mut headers = self.generated.inner().clone(); if let Some(reason) = audit_log_reason { - let mut value = HeaderValue::from_str(reason) - .map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?; - value.set_sensitive(true); - headers.insert("x-audit-log-reason", value); + headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?); } Ok(headers) } @@ -422,11 +419,27 @@ impl std::fmt::Display for ApiError { } } +fn audit_log_reason_header(reason: &str) -> ApiResult { + let mut value = HeaderValue::from_bytes(reason.as_bytes()) + .map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?; + value.set_sensitive(true); + Ok(value) +} + #[cfg(test)] mod tests { use super::*; use serde_json::{Value, json}; + #[test] + fn audit_log_reason_header_carries_utf8_bytes() { + let reason = "§ 3 Regel – wiederholt 日本"; + let value = audit_log_reason_header(reason).expect("valid reason header"); + assert_eq!(value.as_bytes(), reason.as_bytes()); + assert!(value.is_sensitive()); + assert!(audit_log_reason_header("line one\nline two").is_err()); + } + fn response(status: u16, body: &'static str) -> reqwest::Response { axum::http::Response::builder() .status(status) diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index e54d404ad..a1bc703e7 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -122,6 +122,12 @@ pub struct AdminUser { pub pending_bulk_message_deletion_at: Option, pub deletion_reason_code: Option, pub deletion_public_reason: Option, + #[serde(default)] + pub deletion_audit_log_reason: Option, + #[serde(default)] + pub deletion_scheduled_by: Option, + #[serde(default)] + pub deletion_scheduled_at: Option, pub last_active_at: Option, pub last_active_ip: Option, pub last_active_ip_reverse: Option, diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index 87dd429cf..9b87f3091 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -51,28 +51,6 @@ pub struct InstancePolicyResponse { pub services_resolved: InstanceServicesResolved, #[serde(default)] pub services_available: InstanceServicesAvailable, - #[serde(default)] - pub deferred_phone_gate: DeferredPhoneGateResponse, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DeferredPhoneGateResponse { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub window_hours: f64, - #[serde(default)] - pub member_threshold: i64, -} - -impl Default for DeferredPhoneGateResponse { - fn default() -> Self { - Self { - enabled: true, - window_hours: 6.0, - member_threshold: 50, - } - } } impl Default for InstancePolicyResponse { @@ -86,7 +64,6 @@ impl Default for InstancePolicyResponse { services: InstanceServicesOverrides::default(), services_resolved: InstanceServicesResolved::default(), services_available: InstanceServicesAvailable::default(), - deferred_phone_gate: DeferredPhoneGateResponse::default(), } } } @@ -781,18 +758,6 @@ pub struct InstancePolicyUpdateRequest { pub premium_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub services: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub deferred_phone_gate: Option, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct DeferredPhoneGateUpdateRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub window_hours: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub member_threshold: Option, } #[derive(Clone, Debug, Default, Serialize)] diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index 5455c142b..5c0eb00f3 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -439,6 +439,7 @@ impl AdminApiClient { public_reason: public_reason.map(std::borrow::ToOwned::to_owned), reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code") .map_err(ApiError::Parse)?, + replace_pending_deletion_at: None, }; let response = self .generated_with_reason(audit_log_reason)? @@ -449,16 +450,44 @@ impl AdminApiClient { Ok(resp.user) } - pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult { - let response = self - .generated() - .cancel_admin_user_deletion(&snowflake(user_id)) - .await - .map_err(|e| self.generated_error(e))?; - let resp: UserMutationResponse = self.generated_value(response.into_inner())?; + pub async fn cancel_deletion( + &self, + user_id: &str, + expected_pending_deletion_at: &str, + notify_user: bool, + audit_log_reason: Option<&str>, + ) -> ApiResult { + let body = serde_json::json!({ + "expected_pending_deletion_at": expected_pending_deletion_at, + "notify_user": notify_user, + }); + let resp: UserMutationResponse = self + .delete_with_reason( + &format!("/admin/users/{}/deletion", urlencoding::encode(user_id)), + Some(&body), + audit_log_reason, + ) + .await?; Ok(resp.user) } + pub async fn annotate_ban( + &self, + user_id: &str, + ban_audit_log_id: &str, + note: &str, + ) -> ApiResult<()> { + let body = serde_json::json!({ + "ban_audit_log_id": ban_audit_log_id, + "note": note, + }); + self.post_void( + &format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)), + Some(&body), + ) + .await + } + pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult { let body = generated_types::AdminUserDobUpdateRequest { date_of_birth: dob.to_owned(), diff --git a/fluxer_admin/src/routes/bans.rs b/fluxer_admin/src/routes/bans.rs index f718d3f17..387873ade 100644 --- a/fluxer_admin/src/routes/bans.rs +++ b/fluxer_admin/src/routes/bans.rs @@ -23,10 +23,6 @@ pub fn router() -> Router { Router::new() .route("/ip-bans", get(ip_bans).post(ip_bans_post)) .route("/email-bans", get(email_bans).post(email_bans_post)) - .route( - "/suspicious-email-domains", - get(suspicious_email_domains).post(suspicious_email_domains_post), - ) .route("/phrase-bans", get(phrase_bans).post(phrase_bans_post)) .route("/url-bans", get(url_bans).post(url_bans_post)) .route( @@ -72,7 +68,6 @@ macro_rules! ban_get { ban_get!(ip_bans, "ip-bans"); ban_get!(email_bans, "email-bans"); -ban_get!(suspicious_email_domains, "suspicious-email-domains"); ban_get!(phrase_bans, "phrase-bans"); ban_get!(url_bans, "url-bans"); ban_get!(file_sha_bans, "file-sha-bans"); @@ -141,7 +136,6 @@ macro_rules! ban_post { ban_post!(ip_bans_post, "ip-bans"); ban_post!(email_bans_post, "email-bans"); -ban_post!(suspicious_email_domains_post, "suspicious-email-domains"); ban_post!(phrase_bans_post, "phrase-bans"); ban_post!(url_bans_post, "url-bans"); ban_post!(file_sha_bans_post, "file-sha-bans"); diff --git a/fluxer_admin/src/routes/bans_actions.rs b/fluxer_admin/src/routes/bans_actions.rs index 2524187e6..666f521dc 100644 --- a/fluxer_admin/src/routes/bans_actions.rs +++ b/fluxer_admin/src/routes/bans_actions.rs @@ -116,11 +116,6 @@ async fn execute_single_ban( let result = match ban_type { "ip-bans" => client.ban_ip(value, audit_log_reason).await, "email-bans" => client.ban_email(value, audit_log_reason).await, - "suspicious-email-domains" => { - client - .add_suspicious_email_domain(value, audit_log_reason) - .await - } "phrase-bans" => client.ban_phrase(value, audit_log_reason).await, "url-bans" => client.ban_url(value, audit_log_reason).await, "file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await, @@ -143,11 +138,6 @@ async fn execute_single_unban( let result = match ban_type { "ip-bans" => client.unban_ip(value, audit_log_reason).await, "email-bans" => client.unban_email(value, audit_log_reason).await, - "suspicious-email-domains" => { - client - .remove_suspicious_email_domain(value, audit_log_reason) - .await - } "phrase-bans" => client.unban_phrase(value, audit_log_reason).await, "url-bans" => client.unban_url(value, audit_log_reason).await, "file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await, @@ -169,7 +159,6 @@ async fn execute_check( let result = match ban_type { "ip-bans" => client.check_ip_ban(value).await, "email-bans" => client.check_email_ban(value).await, - "suspicious-email-domains" => client.check_suspicious_email_domain(value).await, "phrase-bans" => client.check_phrase_ban(value).await, "url-bans" => client.check_url_ban(value).await, "file-sha-bans" => client.check_file_sha_ban(value).await, diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index cf5184381..e33eef902 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -8,20 +8,19 @@ use crate::{ AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest, - CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest, - DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS, - ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest, - GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, - InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, - InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest, - InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest, - InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest, - InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest, - InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, - InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, - LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode, - ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode, - SsoConfigUpdateRequest, VoiceE2eeScope, + CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest, + EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest, + GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, + InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, + InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest, + InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, + InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest, + InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest, + InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, + InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, + InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, + LimitRuleFilters, PremiumMode, ProfileTimezoneConfigUpdateRequest, + PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope, }, }, config::AdminConfig, @@ -808,7 +807,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest { _ => None, }; let services = build_services_update(form); - let deferred_phone_gate = build_deferred_phone_gate_update(form); InstanceConfigUpdateRequest { policy: Some(InstancePolicyUpdateRequest { single_community_enabled: None, @@ -816,36 +814,11 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest { direct_messages_disabled, premium_mode, services, - deferred_phone_gate, }), ..Default::default() } } -fn build_deferred_phone_gate_update( - form: &MultiValueForm, -) -> Option { - let enabled = form - .first("policy_deferred_phone_gate_enabled") - .map(|value| value == "true"); - let window_hours = form - .first("policy_deferred_phone_gate_window_hours") - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0.0); - let member_threshold = form - .first("policy_deferred_phone_gate_member_threshold") - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0); - if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() { - return None; - } - Some(DeferredPhoneGateUpdateRequest { - enabled, - window_hours, - member_threshold, - }) -} - fn build_services_update(form: &MultiValueForm) -> Option { let parse_tristate = |key: &str| match form.first(key) { Some("inherit") => Some(None), @@ -980,7 +953,6 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest { direct_messages_disabled: None, premium_mode: None, services: None, - deferred_phone_gate: None, }), ..Default::default() } diff --git a/fluxer_admin/src/routes/user_actions.rs b/fluxer_admin/src/routes/user_actions.rs index 0c529d8bd..5830d641c 100644 --- a/fluxer_admin/src/routes/user_actions.rs +++ b/fluxer_admin/src/routes/user_actions.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - admin_flags, api::client::AdminApiClient, middleware::flash::FlashData, + admin_flags, + api::client::{AdminApiClient, ApiError}, + middleware::flash::FlashData, utils::forms::MultiValueForm, }; use std::collections::HashSet; @@ -303,11 +305,53 @@ pub async fn dispatch( "Failed to schedule user deletion", ) } - "cancel_deletion" => DispatchOutcome::from_result( - client.cancel_deletion(user_id).await, - "User deletion cancelled successfully", - "Failed to cancel user deletion", - ), + "cancel_deletion" => { + let Some(expected) = get("expected_pending_deletion_at") else { + return DispatchOutcome::error( + "The pending deletion is missing from the form. Reload and review.", + ); + }; + if !form.bool_value("confirm") { + return DispatchOutcome::error( + "Confirm whose deletion you are cancelling before submitting", + ); + } + let Some(private_reason) = get("private_reason") else { + return DispatchOutcome::error("A private reason is required to cancel a deletion"); + }; + let notify_user = form.bool_value("notify_user"); + match client + .cancel_deletion(user_id, &expected, notify_user, Some(&private_reason)) + .await + { + Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"), + Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error( + "The pending deletion changed since this page loaded. Reload and review.", + ), + Err(error) => { + tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion"); + DispatchOutcome::error("Failed to cancel user deletion") + } + } + } + "annotate_ban" => { + let Some(ban_audit_log_id) = get("ban_audit_log_id") else { + return DispatchOutcome::error("The ban audit log entry is missing from the form"); + }; + let Some(note) = get("note") else { + return DispatchOutcome::error("Note is required"); + }; + match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await { + Ok(()) => DispatchOutcome::success("Note added to the ban"), + Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error( + "The ban changed since this page loaded. Reload and review.", + ), + Err(error) => { + tracing::warn!(%error, user_id, "admin API request failed: annotate ban"); + DispatchOutcome::error("Failed to add the note to the ban") + } + } + } "change_dob" => { let Some(dob) = get("date_of_birth") else { return DispatchOutcome::error("Date of birth is required"); diff --git a/fluxer_admin/src/routes/user_tabs.rs b/fluxer_admin/src/routes/user_tabs.rs index 4b85f75b5..f76b9c965 100644 --- a/fluxer_admin/src/routes/user_tabs.rs +++ b/fluxer_admin/src/routes/user_tabs.rs @@ -111,11 +111,47 @@ pub async fn render( query.delete_all_messages_channel_count.unwrap_or(0), query.delete_all_messages_message_count.unwrap_or(0), )); + let deletion_scheduler = match u.deletion_scheduled_by.as_deref() { + Some(scheduler_id) if u.pending_deletion_at.is_some() && scheduler_id != u.id => { + client + .get_user_by_id(scheduler_id) + .await + .log_error("load deletion scheduler") + } + _ => None, + }; + let ban_logs = if u.temp_banned_until.is_some() + && acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW) + { + client + .search_audit_logs(&SearchAuditLogsParams { + query: None, + admin_user_id: None, + target_id: Some(user_id.to_owned()), + target_type: Some("user".to_owned()), + access: Some("write".to_owned()), + sort_by: Some("created_at".to_owned()), + sort_order: Some("desc".to_owned()), + limit: 100, + offset: 0, + }) + .await + .log_error("load ban audit logs") + .map(|response| response.logs) + .unwrap_or_default() + } else { + Vec::new() + }; + let context = tabs::moderation::ModerationContext { + deletion_scheduler: deletion_scheduler.as_ref(), + current_ban: tabs::moderation::find_current_ban(&u, &ban_logs), + }; Some(tabs::moderation::moderation_tab( config, &u, csrf_token, admin_acls, + &context, query.message_shred_job_id.as_deref(), message_shred_status.as_ref(), delete_all_messages_dry_run, diff --git a/fluxer_admin/src/templates/layout_sidebar_nav.rs b/fluxer_admin/src/templates/layout_sidebar_nav.rs index 8575f8e67..2cc5bede5 100644 --- a/fluxer_admin/src/templates/layout_sidebar_nav.rs +++ b/fluxer_admin/src/templates/layout_sidebar_nav.rs @@ -114,16 +114,6 @@ pub const NAV_SECTIONS: &[NavSection] = &[ acl::BAN_EMAIL_REMOVE ] ), - item!( - "Suspicious Email Domains", - "/suspicious-email-domains", - "suspicious-email-domains", - [ - acl::SUSPICIOUS_EMAIL_DOMAIN_CHECK, - acl::SUSPICIOUS_EMAIL_DOMAIN_ADD, - acl::SUSPICIOUS_EMAIL_DOMAIN_REMOVE, - ] - ), item!( "Phrase Bans", "/phrase-bans", diff --git a/fluxer_admin/src/templates/pages/audit_logs_table.rs b/fluxer_admin/src/templates/pages/audit_logs_table.rs index 2aa3e419c..1946c4f19 100644 --- a/fluxer_admin/src/templates/pages/audit_logs_table.rs +++ b/fluxer_admin/src/templates/pages/audit_logs_table.rs @@ -25,7 +25,9 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant { | "ban_ip" | "ban_email" => BadgeVariant::Danger, "unban" | "cancel_deletion" | "unban_ip" | "unban_email" => BadgeVariant::Success, - "update_flags" | "update_features" | "set_acls" | "update_settings" => BadgeVariant::Info, + "update_flags" | "update_features" | "set_acls" | "update_settings" | "annotate_ban" => { + BadgeVariant::Info + } "delete_message" => BadgeVariant::Warning, _ => BadgeVariant::Default, } diff --git a/fluxer_admin/src/templates/pages/bans.rs b/fluxer_admin/src/templates/pages/bans.rs index ae1ae924f..7b952de92 100644 --- a/fluxer_admin/src/templates/pages/bans.rs +++ b/fluxer_admin/src/templates/pages/bans.rs @@ -45,17 +45,6 @@ pub const BAN_CONFIGS: &[BanConfig] = &[ active_page: "email-bans", show_bulk_tools: false, }, - BanConfig { - title: "Suspicious Email Domains", - route: "/suspicious-email-domains", - input_label: "Email Domain", - input_name: "domain", - input_type: "text", - placeholder: "mail.ru", - entity_name: "Domain", - active_page: "suspicious-email-domains", - show_bulk_tools: false, - }, BanConfig { title: "Phrase Bans", route: "/phrase-bans", diff --git a/fluxer_admin/src/templates/pages/bulk_actions.rs b/fluxer_admin/src/templates/pages/bulk_actions.rs index 9c413c1be..ab6eeed68 100644 --- a/fluxer_admin/src/templates/pages/bulk_actions.rs +++ b/fluxer_admin/src/templates/pages/bulk_actions.rs @@ -410,6 +410,9 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup { } }, )) + p class="text-neutral-500 text-sm" { + "Users that already have a pending deletion are skipped and listed as failed with the reason already scheduled. Cancel those from the user page first to schedule them again." + } (text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation")) (form_field_group("Days Until Deletion", "days_until_deletion", true, None, Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."), diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index a7a0d6e8e..b2ea047c6 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -119,7 +119,6 @@ pub fn instance_config_page( instance_config.self_hosted, )) (sso_config_section(base, csrf_token, &instance_config.sso)) - (deferred_phone_gate_form(base, csrf_token, &instance_config.policy)) }, )) @if instance_config.self_hosted { @@ -334,57 +333,6 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes } } -fn deferred_phone_gate_form( - base: &str, - csrf_token: &str, - policy: &InstancePolicyResponse, -) -> Markup { - let gate = &policy.deferred_phone_gate; - let status = if gate.enabled { - ("Enabled", BadgeVariant::Success) - } else { - ("Disabled", BadgeVariant::Default) - }; - html! { - div class="space-y-4 border-t border-neutral-200 pt-6" { - div class="flex flex-wrap items-center gap-2" { - h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" } - (badge(status.0, status.1)) - } - p class="text-sm text-neutral-500" { - "When enabled, a phone requirement raised at registration is held back and only \ - applied if the account joins a discoverable community, or one above the member \ - threshold, within the window. Accounts that wait out the window are not challenged. \ - Inbound-SMS requirements are never deferred." - } - form method="post" action={(base) "/instance-config?action=update_policy"} { - (csrf_input(csrf_token)) - div class="space-y-4" { - (select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[ - ("true", "Enabled"), - ("false", "Disabled"), - ], if gate.enabled { "true" } else { "false" })) - (text_input( - "policy_deferred_phone_gate_window_hours", - "Window (hours)", - &gate.window_hours.to_string(), - "6", - )) - (text_input( - "policy_deferred_phone_gate_member_threshold", - "Member threshold", - &gate.member_threshold.to_string(), - "50", - )) - (form_actions(html! { - (submit_button("Save deferred phone verification")) - })) - } - } - } - } -} - fn premium_mode_form( base: &str, csrf_token: &str, diff --git a/fluxer_admin/src/templates/pages/jobs_list.rs b/fluxer_admin/src/templates/pages/jobs_list.rs index 32e768b38..06bfde907 100644 --- a/fluxer_admin/src/templates/pages/jobs_list.rs +++ b/fluxer_admin/src/templates/pages/jobs_list.rs @@ -31,7 +31,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup { div class="flex flex-col gap-2" { label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" } input type="text" id="task_type" name="task_type" - value=(p.task_type_filter) placeholder="syncDisposableEmailDomains" + value=(p.task_type_filter) placeholder="syncUrlBlocklists" class=(FORM_INPUT_CLASS); } div class="flex flex-col gap-2" { diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs index 9c3bf9133..bca935084 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs @@ -4,13 +4,14 @@ use crate::{ acl, api::{ client::{ApiError, ApiResult}, - types::{AdminUser, MessageShredStatusResponse}, + types::{AdminUser, AuditLogEntry, MessageShredStatusResponse}, }, config::AdminConfig, templates::components::{ - form::{csrf_input, danger_button, form_actions, submit_button}, + form::{checkbox, csrf_input, danger_button, form_actions, submit_button}, page_container::card_with_header, }, + utils::timestamps::format_admin_timestamp, }; use maud::{Markup, html}; @@ -51,11 +52,60 @@ const DELETION_REASONS: &[(&str, &str)] = &[ ("22", "Impersonation or fake identity"), ]; +pub struct CurrentBan<'a> { + pub entry: &'a AuditLogEntry, + pub notes: Vec<&'a AuditLogEntry>, +} + +#[derive(Default)] +pub struct ModerationContext<'a> { + pub deletion_scheduler: Option<&'a AdminUser>, + pub current_ban: Option>, +} + +pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option> { + let banned_until = user.temp_banned_until.as_deref()?; + let entry = logs.iter().find(|log| { + log.action == "temp_ban" + && log.target_id == user.id + && log.metadata.get("banned_until").map(String::as_str) == Some(banned_until) + })?; + let mut notes: Vec<&AuditLogEntry> = logs + .iter() + .filter(|log| { + log.action == "annotate_ban" + && log.metadata.get("ban_audit_log_id") == Some(&entry.log_id) + }) + .collect(); + notes.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Some(CurrentBan { entry, notes }) +} + +fn deletion_reason_label(code: i32) -> String { + let value = code.to_string(); + DELETION_REASONS + .iter() + .find(|(candidate, _)| *candidate == value) + .map_or_else( + || format!("Reason {code}"), + |(_, label)| (*label).to_owned(), + ) +} + +fn admin_name(entry: &AuditLogEntry) -> String { + entry.admin_user.as_ref().map_or_else( + || entry.admin_user_id.clone(), + |admin| admin.username.clone(), + ) +} + +#[allow(clippy::too_many_arguments)] pub fn moderation_tab( config: &AdminConfig, user: &AdminUser, csrf_token: &str, admin_acls: &[String], + context: &ModerationContext<'_>, message_shred_job_id: Option<&str>, message_shred_status: Option<&ApiResult>, delete_all_messages_dry_run: Option<(u64, u64)>, @@ -66,8 +116,8 @@ pub fn moderation_tab( html! { div class="space-y-6" { div class="grid grid-cols-1 gap-6 md:grid-cols-2" { - (ban_actions_card(base, user, csrf_token)) - (deletion_card(base, user, csrf_token)) + (ban_actions_card(base, user, csrf_token, context.current_ban.as_ref())) + (deletion_card(base, user, csrf_token, context.deletion_scheduler)) } @if can_delete_all_messages { (delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run)) @@ -79,10 +129,16 @@ pub fn moderation_tab( } } -fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup { +fn ban_actions_card( + base: &str, + user: &AdminUser, + csrf_token: &str, + current_ban: Option<&CurrentBan<'_>>, +) -> Markup { html! { (card_with_header("Ban Actions", html! { @if user.temp_banned_until.is_some() { + (current_ban_details(base, user, csrf_token, current_ban)) form method="post" action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} { (csrf_input(csrf_token)) @@ -129,16 +185,160 @@ fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup { } } -fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup { +fn current_ban_details( + base: &str, + user: &AdminUser, + csrf_token: &str, + current_ban: Option<&CurrentBan<'_>>, +) -> Markup { + let Some(ban) = current_ban else { + return html! { + p class="mb-4 text-sm text-neutral-500" { + "The audit log entry of this ban could not be loaded, so notes cannot be added here." + } + }; + }; + html! { + div class="mb-4 space-y-3" { + dl class="space-y-1 text-sm text-neutral-700" { + div { + dt class="inline font-medium" { "Banned by: " } + dd class="inline" { + a href={(base) "/users/" (ban.entry.admin_user_id)} class="underline" { + (admin_name(ban.entry)) + } + " on " (format_admin_timestamp(&ban.entry.created_at)) + } + } + div { + dt class="inline font-medium" { "Reason: " } + dd class="inline" { (ban.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) } + } + } + @if !ban.notes.is_empty() { + ul class="space-y-1 text-sm text-neutral-700" { + @for note in &ban.notes { + li { + span class="font-medium" { (admin_name(note)) } + " (" (format_admin_timestamp(¬e.created_at)) "): " + (note.audit_log_reason.as_deref().unwrap_or("")) + } + } + } + } + form method="post" + action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} { + (csrf_input(csrf_token)) + input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id); + div class="space-y-3" { + (form_label("Add a note to this ban")) + textarea name="note" rows="2" required maxlength="512" + placeholder="Appended to the ban. The original reason is kept." + 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" {} + (form_actions(html! { + (submit_button("Add Note")) + })) + } + } + } + } +} + +fn pending_deletion_summary( + base: &str, + user: &AdminUser, + scheduler: Option<&AdminUser>, +) -> (String, Markup) { + let scheduler_name = match (user.deletion_scheduled_by.as_deref(), scheduler) { + (None, _) => "an unrecorded source".to_owned(), + (Some(id), _) if id == user.id => "the user".to_owned(), + (Some(_), Some(scheduler)) => scheduler.username.clone(), + (Some(id), None) => id.to_owned(), + }; + let reason = user + .deletion_reason_code + .map_or_else(|| "no reason code".to_owned(), deletion_reason_label); + let due = user + .pending_deletion_at + .as_deref() + .map(format_admin_timestamp) + .unwrap_or_default(); + let markup = html! { + dl class="mb-4 space-y-1 text-sm text-neutral-700" { + div { + dt class="inline font-medium" { "Scheduled by: " } + dd class="inline" { + @match user.deletion_scheduled_by.as_deref() { + Some(id) => { + a href={(base) "/users/" (id)} class="underline" { (scheduler_name) } + } + None => { (scheduler_name) } + } + @if let Some(at) = user.deletion_scheduled_at.as_deref() { + " on " (format_admin_timestamp(at)) + } + } + } + div { + dt class="inline font-medium" { "Due: " } + dd class="inline" { (due) } + } + div { + dt class="inline font-medium" { "Reason: " } + dd class="inline" { (reason) } + } + @if let Some(public_reason) = &user.deletion_public_reason { + div { + dt class="inline font-medium" { "Public reason: " } + dd class="inline" { (public_reason) } + } + } + @if let Some(private_reason) = &user.deletion_audit_log_reason { + div { + dt class="inline font-medium" { "Private reason: " } + dd class="inline" { (private_reason) } + } + } + } + }; + ( + format!("Cancel {scheduler_name}'s deletion ({reason}, due {due})"), + markup, + ) +} + +fn deletion_card( + base: &str, + user: &AdminUser, + csrf_token: &str, + scheduler: Option<&AdminUser>, +) -> Markup { html! { (card_with_header("Account Deletion", html! { - @if user.pending_deletion_at.is_some() { + @if let Some(pending) = &user.pending_deletion_at { + @let (confirmation, summary) = pending_deletion_summary(base, user, scheduler); + (summary) form method="post" action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} { (csrf_input(csrf_token)) - (form_actions(html! { - (submit_button("Cancel Deletion")) - })) + input type="hidden" name="expected_pending_deletion_at" value=(pending); + 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)..." + 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"; + (checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true)) + (checkbox("confirm", "true", &confirmation, false, true)) + (form_actions(html! { + (danger_button("Cancel Deletion")) + })) + } } } @else { form method="post" @@ -153,11 +353,12 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup { focus:border-brand-primary focus:outline-none \ focus:ring-1 focus:ring-brand-primary"; (form_label("Reason")) - select name="reason_code" + select name="reason_code" required 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" { + option value="" disabled selected { "Choose a reason" } @for &(value, label) in DELETION_REASONS { option value=(value) { (label) } } @@ -513,3 +714,112 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#" }); })(); "#; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn user(extra: Value) -> AdminUser { + let mut value = + json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"}); + if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) { + target.extend(fields.clone()); + } + serde_json::from_value(value).expect("valid admin user") + } + + fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry { + serde_json::from_value(json!({ + "log_id": log_id, + "admin_user_id": "1400000000000000001", + "admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null}, + "action": action, + "target_id": "1500000000000000001", + "target_type": "user", + "audit_log_reason": reason, + "metadata": metadata, + "created_at": "2026-09-01T10:00:00.000Z" + })) + .expect("valid audit log entry") + } + + #[test] + fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() { + let target = user(json!({ + "pending_deletion_at": "2026-10-30T17:40:29.690Z", + "deletion_reason_code": 3, + "deletion_public_reason": "Spam", + "deletion_audit_log_reason": "Report batch 12", + "deletion_scheduled_by": "1400000000000000001", + "deletion_scheduled_at": "2026-08-31T17:40:29.690Z" + })); + let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"})); + let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string(); + assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#)); + assert!(markup.contains("lilith")); + assert!(markup.contains("Report batch 12")); + assert!( + markup.contains( + r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""# + ) + ); + assert!(markup.contains(r#"name="notify_user" value="true""#)); + assert!(!markup.contains(r#"name="notify_user" value="true" checked"#)); + assert!(markup.contains("Cancel lilith's deletion (Spam, due")); + assert!(markup.contains(r#"name="private_reason" required"#)); + } + + #[test] + fn schedule_form_makes_the_reason_an_explicit_choice() { + let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string(); + assert!(markup.contains(r#""#)); + assert!(!markup.contains(r#"