Compare commits

...
627 changed files with 22922 additions and 40824 deletions
-6
View File
@@ -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:
-3
View File
@@ -107,9 +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
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
+1 -10
View File
@@ -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
@@ -224,12 +221,6 @@ FLUXER_EMAIL_SMTP_USERNAME=
FLUXER_EMAIL_SMTP_PASSWORD=
FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. These are ceilings, not allocations, and the defaults suit a
-8
View File
@@ -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,13 +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:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
+149 -376
View File
@@ -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"
@@ -10523,11 +10627,9 @@
"additionalProperties": false
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10720,16 +10822,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": [
@@ -10740,8 +10832,7 @@
"premium_mode",
"services",
"services_resolved",
"services_available",
"deferred_phone_gate"
"services_available"
],
"additionalProperties": false
},
@@ -10760,31 +10851,6 @@
"required": ["api_key_set", "effective_available"],
"additionalProperties": false
},
"captcha": {
"type": "object",
"properties": {
"provider": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"}]
},
"effective_provider": {"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"},
"hcaptcha_site_key": {"nullable": true, "type": "string"},
"hcaptcha_secret_key_set": {"type": "boolean"},
"turnstile_site_key": {"nullable": true, "type": "string"},
"turnstile_secret_key_set": {"type": "boolean"},
"effective_enabled": {"type": "boolean"}
},
"required": [
"provider",
"effective_provider",
"hcaptcha_site_key",
"hcaptcha_secret_key_set",
"turnstile_site_key",
"turnstile_secret_key_set",
"effective_enabled"
],
"additionalProperties": false
},
"email": {
"type": "object",
"properties": {
@@ -10847,7 +10913,7 @@
"additionalProperties": false
}
},
"required": ["gif", "youtube", "captcha", "email", "bluesky"],
"required": ["gif", "youtube", "email", "bluesky"],
"additionalProperties": false
},
"media": {
@@ -10959,11 +11025,9 @@
"required": [
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"captcha",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11095,23 +11159,12 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
},
"voice_noise_suppression": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -11194,20 +11247,6 @@
"type": "object",
"properties": {"api_key": {"nullable": true, "type": "string", "maxLength": 4096}}
},
"captcha": {
"nullable": true,
"type": "object",
"properties": {
"provider": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"}]
},
"hcaptcha_site_key": {"nullable": true, "type": "string", "maxLength": 4096},
"hcaptcha_secret_key": {"nullable": true, "type": "string", "maxLength": 4096},
"turnstile_site_key": {"nullable": true, "type": "string", "maxLength": 4096},
"turnstile_secret_key": {"nullable": true, "type": "string", "maxLength": 4096}
}
},
"email": {
"nullable": true,
"type": "object",
@@ -11318,15 +11357,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}
}
}
}
},
@@ -12329,17 +12359,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": {
@@ -12382,7 +12402,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"},
@@ -12759,7 +12778,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12777,7 +12796,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12807,7 +12826,7 @@
"maximum": 365
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12828,7 +12847,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12841,7 +12860,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"
@@ -12870,9 +12889,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",
@@ -13185,19 +13201,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": {
@@ -13440,12 +13443,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"
}
]
},
@@ -15211,7 +15214,6 @@
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
}
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
"x-enumNames": ["open", "approval", "closed"],
@@ -15230,55 +15232,12 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"ProfileTimezoneConfigUpdateRequest": {
"CaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
"cost": {"type": "integer", "minimum": 1000, "maximum": 20000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
}
},
"DomainMigrationConfigUpdateRequest": {
@@ -15308,50 +15267,6 @@
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"default_backend": {"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]},
"enabled_backends": {
"maxItems": 7,
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"guild_overrides": {
"maxItems": 200,
"type": "array",
"items": {
"type": "object",
"properties": {
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"required": ["guild_id", "backend"]
}
},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
"GatewayRolloutConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15366,10 +15281,6 @@
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
}
},
"VoiceNoiseSuppressionBackendSchema": {
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"BillingLegacyPricesSchema": {
"type": "object",
"additionalProperties": {
@@ -15458,100 +15369,14 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"ProfileTimezoneConfigResponse": {
"CaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "profile-timezone-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
"enabled": {"default": true, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 20000},
"max_counter": {"default": 1000, "type": "integer", "minimum": 100, "maximum": 20000}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"default": false, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
"max_counter"
],
"required": ["enabled", "cost", "max_counter"],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
@@ -15619,76 +15444,6 @@
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"default_backend": {
"default": "standard",
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]
},
"enabled_backends": {
"default": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
"maxItems": 7,
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"default": true, "type": "boolean"},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"default": "voice-ns-v1", "type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"guild_overrides": {
"default": [],
"maxItems": 200,
"type": "array",
"items": {
"type": "object",
"properties": {
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"required": ["guild_id", "backend"],
"additionalProperties": false
}
},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
"enabled",
"config_version",
"default_backend",
"enabled_backends",
"allow_user_override",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
],
"additionalProperties": false
},
"GatewayRolloutConfigResponse": {
"type": "object",
"properties": {
@@ -15827,14 +15582,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"}},
@@ -15875,6 +15645,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",
-6
View File
@@ -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,
+30 -79
View File
@@ -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<BanCheckResult> {
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<generated_types::AdminBlocklistListType> {
@@ -380,15 +340,6 @@ fn blocklist_delete_scope(
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
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,
+17 -4
View File
@@ -90,10 +90,7 @@ impl AdminApiClient {
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
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<HeaderValue> {
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)
+6
View File
@@ -122,6 +122,12 @@ pub struct AdminUser {
pub pending_bulk_message_deletion_at: Option<String>,
pub deletion_reason_code: Option<i32>,
pub deletion_public_reason: Option<String>,
#[serde(default)]
pub deletion_audit_log_reason: Option<String>,
#[serde(default)]
pub deletion_scheduled_by: Option<String>,
#[serde(default)]
pub deletion_scheduled_at: Option<String>,
pub last_active_at: Option<String>,
pub last_active_ip: Option<String>,
pub last_active_ip_reverse: Option<String>,
+18 -338
View File
@@ -3,7 +3,6 @@
use serde::{Deserialize, Serialize};
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
@@ -22,15 +21,11 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
@@ -54,28 +49,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 {
@@ -89,7 +62,6 @@ impl Default for InstancePolicyResponse {
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -128,8 +100,6 @@ pub struct InstanceIntegrationsResponse {
#[serde(default)]
pub youtube: InstanceYoutubeIntegrationResponse,
#[serde(default)]
pub captcha: InstanceCaptchaIntegrationResponse,
#[serde(default)]
pub email: InstanceEmailIntegrationResponse,
#[serde(default)]
pub bluesky: InstanceBlueskyIntegrationResponse,
@@ -150,21 +120,6 @@ pub struct InstanceYoutubeIntegrationResponse {
pub effective_available: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceCaptchaIntegrationResponse {
pub provider: Option<String>,
#[serde(default)]
pub effective_provider: String,
pub hcaptcha_site_key: Option<String>,
#[serde(default)]
pub hcaptcha_secret_key_set: bool,
pub turnstile_site_key: Option<String>,
#[serde(default)]
pub turnstile_secret_key_set: bool,
#[serde(default)]
pub effective_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceEmailIntegrationResponse {
pub enabled: Option<bool>,
@@ -468,107 +423,8 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
pub const ALL: [Self; 7] = [
Self::None,
Self::Standard,
Self::Gate,
Self::Speex,
Self::Rnnoise,
Self::Gtcrn,
Self::DeepFilter,
];
pub fn label(&self) -> &'static str {
match self {
Self::None => "None (pass-through)",
Self::Standard => "Standard (WebRTC)",
Self::Gate => "Noise gate",
Self::Speex => "Speex",
Self::Rnnoise => "RNNoise",
Self::Gtcrn => "GTCRN",
Self::DeepFilter => "DeepFilterNet",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
default_backend: NoiseSuppressionBackend::Standard,
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
allow_user_override: true,
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
@@ -640,108 +496,32 @@ pub struct DomainMigrationConfigUpdateRequest {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct AltchaCaptchaConfigResponse {
pub struct CaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
impl Default for CaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
enabled: true,
cost: 5_000,
max_counter: 10_000,
max_counter: 1_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
pub struct CaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -856,15 +636,11 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -883,18 +659,6 @@ pub struct InstancePolicyUpdateRequest {
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeferredPhoneGateUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_threshold: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -914,8 +678,6 @@ pub struct InstanceIntegrationsUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub youtube: Option<InstanceYoutubeIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<InstanceCaptchaIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<InstanceEmailIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bluesky: Option<InstanceBlueskyIntegrationUpdateRequest>,
@@ -933,20 +695,6 @@ pub struct InstanceYoutubeIntegrationUpdateRequest {
pub api_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceCaptchaIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_secret_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_secret_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceEmailIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1186,76 +934,35 @@ mod tests {
use serde_json::json;
#[test]
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
assert_eq!(
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
json!([
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
])
);
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
}
#[test]
fn default_instance_experiment_config_matches_the_published_contract() {
fn default_instance_config_sections_match_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
.expect("default altcha captcha config");
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
.expect("default profile timezone config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
serde_json::from_value(altcha_captcha.clone())
.expect("generated altcha captcha config contract");
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
serde_json::from_value(profile_timezone.clone())
.expect("generated profile timezone config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_altcha_captcha)
.expect("serializable generated altcha captcha config"),
altcha_captcha
);
assert_eq!(
serde_json::to_value(generated_profile_timezone)
.expect("serializable generated profile timezone config"),
profile_timezone
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
);
assert_eq!(
serde_json::to_value(generated_delivery)
@@ -1263,10 +970,8 @@ mod tests {
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1278,31 +983,6 @@ mod tests {
}
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
+36 -7
View File
@@ -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<AdminUser> {
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<AdminUser> {
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<AdminUser> {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),
-6
View File
@@ -23,10 +23,6 @@ pub fn router() -> Router<AppState> {
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");
-11
View File
@@ -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,
+64 -625
View File
@@ -4,25 +4,22 @@ use crate::{
api::{
client::AdminApiClient,
types::{
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, 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, NoiseSuppressionBackend,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -207,10 +204,6 @@ pub async fn instance_config_post(
let update = build_media_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_billing" => match super::billing_actions::build_billing_update(&form) {
Ok(update) => {
let result = client.update_instance_config(&update).await;
@@ -227,11 +220,7 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -487,7 +476,6 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
@@ -528,22 +516,13 @@ fn parse_experiment_rollout_salt(
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
if !salt
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
Ok(Some(salt.to_owned()))
}
fn is_experiment_snowflake(value: &str) -> bool {
@@ -578,117 +557,6 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
Ok(ids)
}
fn parse_voice_noise_suppression_guild_overrides(
value: &str,
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
for (index, line) in value.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_experiment_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
}
let backend = backend.trim().parse().map_err(|_| {
format!("Guild overrides line {line_number} must name a supported backend")
})?;
if let Some(existing) = overrides
.iter()
.find(|existing| existing.guild_id == guild_id)
{
if existing.backend != backend {
return Err(format!(
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
));
}
continue;
}
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
return Err(format!(
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
));
}
overrides.push(VoiceNoiseSuppressionGuildOverride {
guild_id: guild_id.to_owned(),
backend,
});
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let selected: Vec<NoiseSuppressionBackend> = form
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
.into_iter()
.map(|value| {
value.parse().map_err(|_| {
"Enabled backends must name supported noise suppression backends".to_owned()
})
})
.collect::<Result<_, _>>()?;
let enabled_backends = NoiseSuppressionBackend::ALL
.into_iter()
.filter(|backend| selected.contains(backend))
.collect();
Ok(InstanceConfigUpdateRequest {
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
enabled: Some(form.bool_value("voice_ns_enabled")),
default_backend: form
.first("voice_ns_default_backend")
.map(|value| {
value.parse().map_err(|_| {
"Default backend must name a supported noise suppression backend".to_owned()
})
})
.transpose()?,
enabled_backends: Some(enabled_backends),
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
)?,
}),
..Default::default()
})
}
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
@@ -711,10 +579,7 @@ fn build_domain_migration_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"domain_migration_rollout_salt",
)?,
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
@@ -744,94 +609,29 @@ fn build_domain_migration_update(
})
}
fn build_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
captcha: Some(CaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("captcha_enabled")),
cost: parse_form_number(
form,
"altcha_captcha_cost",
"captcha_cost",
"Cost",
*ALTCHA_CAPTCHA_COST_RANGE.start(),
*ALTCHA_CAPTCHA_COST_RANGE.end(),
*CAPTCHA_COST_RANGE.start(),
*CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"altcha_captcha_max_counter",
"captcha_max_counter",
"Maximum counter",
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
*CAPTCHA_MAX_COUNTER_RANGE.start(),
*CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_profile_timezone_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
enabled: Some(form.bool_value("profile_timezone_enabled")),
rollout_basis_points: parse_form_number(
form,
"profile_timezone_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"profile_timezone_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -940,7 +740,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,
@@ -948,36 +747,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<DeferredPhoneGateUpdateRequest> {
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::<f64>().ok())
.filter(|value| *value > 0.0);
let member_threshold = form
.first("policy_deferred_phone_gate_member_threshold")
.and_then(|value| value.parse::<i64>().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<InstanceServicesUpdateRequest> {
let parse_tristate = |key: &str| match form.first(key) {
Some("inherit") => Some(None),
@@ -1021,13 +795,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
api_key: clean("integration_youtube_api_key"),
}),
captcha: Some(InstanceCaptchaIntegrationUpdateRequest {
provider: clean("integration_captcha_provider"),
hcaptcha_site_key: clean("integration_hcaptcha_site_key"),
hcaptcha_secret_key: clean("integration_hcaptcha_secret_key"),
turnstile_site_key: clean("integration_turnstile_site_key"),
turnstile_secret_key: clean("integration_turnstile_secret_key"),
}),
email: Some(InstanceEmailIntegrationUpdateRequest {
enabled: Some(form.bool_value("integration_email_enabled")),
provider: Some("smtp".to_owned()),
@@ -1112,7 +879,6 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
direct_messages_disabled: None,
premium_mode: None,
services: None,
deferred_phone_gate: None,
}),
..Default::default()
}
@@ -1439,77 +1205,6 @@ mod tests {
);
}
#[test]
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
let form = MultiValueForm::parse(
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
);
let request = build_voice_noise_suppression_update(&form).expect("valid form");
let update = request
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
);
assert_eq!(
update.enabled_backends,
Some(vec![
NoiseSuppressionBackend::None,
NoiseSuppressionBackend::DeepFilter
])
);
assert_eq!(update.rollout_basis_points, Some(10_000));
assert_eq!(update.suppression_strength, Some(100));
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
}
#[test]
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_voice_noise_suppression_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
let form = MultiValueForm::parse(
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
@@ -1569,197 +1264,10 @@ mod tests {
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
for (line, message) in [
("456", "Guild overrides line 3 must use guild_id=backend"),
(
"=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"not-a-guild=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"999999999999999999999=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"456=unknown_backend",
"Guild overrides line 3 must name a supported backend",
),
(
"456=",
"Guild overrides line 3 must name a supported backend",
),
(
"123=gate",
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
),
] {
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
.expect_err("invalid guild rule"),
message,
"{line}"
);
}
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
for (key, message, above_max) in [
(
"voice_ns_rollout_basis_points",
"Rollout basis points must be a whole number between 0 and 10000",
"10001",
),
(
"voice_ns_suppression_strength",
"Suppression strength must be a whole number between 0 and 100",
"101",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_basis_points, Some(250));
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
for salt in [
String::new(),
" ".to_owned(),
"é".repeat(65),
"🎲".repeat(33),
] {
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
"Rollout salt must be between 1 and 64 characters"
);
}
}
#[test]
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
let form =
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_salt, Some(salt));
}
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
let overrides = parse_voice_noise_suppression_guild_overrides(
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
).expect("valid guild rules");
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
@@ -1784,6 +1292,14 @@ mod tests {
);
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
}
#[test]
@@ -1822,6 +1338,10 @@ mod tests {
"domain_migration_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
"Rollout salt must be between 1 and 64 characters",
),
(
"domain_migration_rollout_salt=caf%C3%A9",
"Rollout salt must use printable ASCII",
@@ -1861,146 +1381,65 @@ mod tests {
}
#[test]
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
fn build_captcha_update_reads_the_switch_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
b"captcha_enabled=true&captcha_cost=%202000%20&captcha_max_counter=400",
);
let update = build_altcha_captcha_update(&form)
let update = build_captcha_update(&form)
.expect("valid form")
.altcha_captcha
.expect("altcha captcha update");
.captcha
.expect("captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
assert_eq!(update.anonymous_enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
fn build_captcha_update_turns_the_check_off_when_the_box_is_unchecked() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_altcha_captcha_update(&form).expect("valid form");
let request = build_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
serde_json::json!({"captcha": {"enabled": false}})
);
}
#[test]
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"altcha_captcha_cost=999",
"Cost must be a whole number between 1000 and 100000",
"captcha_cost=999",
"Cost must be a whole number between 1000 and 20000",
),
(
"altcha_captcha_max_counter=1000001",
"Maximum counter must be a whole number between 100 and 1000000",
"captcha_cost=20001",
"Cost must be a whole number between 1000 and 20000",
),
(
"altcha_captcha_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
"captcha_max_counter=99",
"Maximum counter must be a whole number between 100 and 20000",
),
(
"captcha_max_counter=20001",
"Maximum counter must be a whole number between 100 and 20000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_altcha_captcha_update(&form).expect_err("invalid field"),
build_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_profile_timezone_update_reads_the_rollout_fields() {
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
let form = MultiValueForm::parse(
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
);
let update = build_profile_timezone_update(&form)
.expect("valid form")
.profile_timezone
.expect("profile timezone update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_profile_timezone_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"profile_timezone": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"voice_ns",
build_voice_noise_suppression_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[test]
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
assert_eq!(
build_profile_timezone_update(&form).expect_err("invalid field"),
"Rollout basis points must be a whole number between 0 and 10000"
build_domain_migration_update(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
);
}
+50 -6
View File
@@ -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, &note).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");
+36
View File
@@ -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,
@@ -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",
@@ -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,
}
-11
View File
@@ -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",
@@ -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."),
@@ -2,15 +2,13 @@
use crate::{
api::types::{
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -120,7 +118,13 @@ 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))
},
))
(config_group(
"Bot protection",
"A proof-of-work check on sign-up, login, password reset and a few other abuse-prone actions.",
html! {
(captcha_section(base, csrf_token, &instance_config.captcha))
},
))
@if instance_config.self_hosted {
@@ -176,10 +180,7 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -336,57 +337,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,
@@ -521,11 +471,6 @@ fn integrations_config_section(
csrf_token: &str,
integrations: &InstanceIntegrationsResponse,
) -> Markup {
let captcha_provider = integrations
.captcha
.provider
.as_deref()
.unwrap_or(integrations.captcha.effective_provider.as_str());
let smtp_port = integrations
.email
.smtp
@@ -557,35 +502,6 @@ fn integrations_config_section(
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
}
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" { "Bot protection" }
(secret_badge("hCaptcha secret", integrations.captcha.hcaptcha_secret_key_set))
(secret_badge("Turnstile secret", integrations.captcha.turnstile_secret_key_set))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
(select_input("integration_captcha_provider", "Provider", &[
("none", "Disabled"),
("hcaptcha", "hCaptcha"),
("turnstile", "Cloudflare Turnstile"),
], captcha_provider))
(text_input(
"integration_hcaptcha_site_key",
"hCaptcha site key",
integrations.captcha.hcaptcha_site_key.as_deref().unwrap_or(""),
"",
))
(password_input("integration_hcaptcha_secret_key", "hCaptcha secret key", Some("Leave blank to keep the current secret.")))
(text_input(
"integration_turnstile_site_key",
"Turnstile site key",
integrations.captcha.turnstile_site_key.as_deref().unwrap_or(""),
"",
))
(password_input("integration_turnstile_secret_key", "Turnstile secret key", Some("Leave blank to keep the current secret.")))
}
}
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" { "Email delivery" }
@@ -1024,234 +940,6 @@ fn gateway_rollout_section(
)
}
fn voice_noise_suppression_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
section_card_with_description(
"Voice Noise Suppression",
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
of them are targeted. While the master switch below is off nothing on this form reaches \
any client: every user keeps the audio pipeline they have today, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout, targeting \
and override fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
(checkbox(
"voice_ns_allow_user_override",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_ns_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"voice_ns_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
))
p class="text-xs text-neutral-500" {
"One per line as guild_id=backend. A guild \
rule targets callers even outside the canary. Always-on user rules \
take precedence, and excluded users stay off. Invalid lines and \
conflicting rules for the same guild prevent the save. \
Unticked backends stay stored but are inactive."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
"Suppression Strength",
&voice_noise_suppression.suppression_strength.to_string(),
Some(0), Some(100), "1",
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
}))
}
}
},
)
}
fn push_relay_section(
base: &str,
csrf_token: &str,
@@ -1487,308 +1175,67 @@ fn domain_migration_section(
)
}
fn altcha_captcha_section(
base: &str,
csrf_token: &str,
altcha_captcha: &AltchaCaptchaConfigResponse,
) -> Markup {
let status = if altcha_captcha.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
section_card_with_description(
"ALTCHA Captcha",
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
selected requesters. The API issues and verifies every challenge itself, so no third \
party is involved. Requests only need a captcha where one is already required, so this \
does nothing while captcha is off for the instance.",
html! {
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (altcha_captcha.config_version)
}
}
(checkbox(
"altcha_captcha_enabled",
"true",
"Serve ALTCHA to the selected requesters",
altcha_captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
requester gets the configured provider and ALTCHA answers are rejected."
}
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
(checkbox(
"altcha_captcha_anonymous_enabled",
"true",
"Serve ALTCHA to logged-out requests",
altcha_captcha.anonymous_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Covers registration, login and password reset. These requests have no \
account to bucket, so this switch applies to all of them at once."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"altcha_captcha_rollout_basis_points",
"Rollout (basis points)",
&altcha_captcha.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"altcha_captcha_rollout_salt",
"Rollout Salt",
&altcha_captcha.rollout_salt,
ALTCHA_CAPTCHA_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get ALTCHA \
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
(number_field(
"altcha_captcha_cost",
"Cost (PBKDF2 iterations per attempt)",
&altcha_captcha.cost.to_string(),
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
"1",
Some("The API spends one attempt at this cost to issue each challenge."),
))
(number_field(
"altcha_captcha_max_counter",
"Maximum counter",
&altcha_captcha.max_counter.to_string(),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
))
(form_actions(html! {
(submit_button("Save ALTCHA Configuration"))
}))
}
}
},
)
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
}
fn profile_timezone_section(
base: &str,
csrf_token: &str,
profile_timezone: &ProfileTimezoneConfigResponse,
) -> Markup {
let status = if profile_timezone.enabled {
("Live", BadgeVariant::Success)
fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse) -> Markup {
let status = if captcha.enabled {
("On", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
("Off", BadgeVariant::Default)
};
let included_user_ids = profile_timezone.included_user_ids.join("\n");
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
let estimate = estimate_low_end_solve_seconds(captcha.cost, captcha.max_counter);
section_card_with_description(
"Profile Timezone",
"Lets the selected users save a time zone in profile settings and show their local time \
on their profile. Users outside the rollout cannot change it, and a saved time zone \
stays hidden from everyone while its owner is outside the rollout.",
"Proof-of-work check",
"Clients solve it in the background. The API issues and verifies every challenge itself, \
and no third party is involved.",
html! {
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (profile_timezone.config_version)
}
}
(checkbox(
"profile_timezone_enabled",
"true",
"Serve profile timezone to the selected users",
profile_timezone.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked nobody \
sees the setting and every saved time zone is hidden."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"profile_timezone_rollout_basis_points",
"Rollout (basis points)",
&profile_timezone.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"profile_timezone_rollout_salt",
"Rollout Salt",
&profile_timezone.rollout_salt,
PROFILE_TIMEZONE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get profile \
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
(badge(status.0, status.1))
}
form method="post" action={(base) "/instance-config?action=update_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
(checkbox(
"profile_timezone_include_premium_users",
"captcha_enabled",
"true",
"Include premium users",
profile_timezone.include_premium_users,
"Require a proof-of-work check",
captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
"On by default. Turning it off removes the check from every request."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
(number_field(
"captcha_cost",
"Cost (PBKDF2 iterations per try)",
&captcha.cost.to_string(),
Some(*CAPTCHA_COST_RANGE.start()),
Some(*CAPTCHA_COST_RANGE.end()),
"1",
Some("Default 5000."),
))
(entry_count_hint(
profile_timezone.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
(number_field(
"captcha_max_counter",
"Maximum counter",
&captcha.max_counter.to_string(),
Some(*CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Default 1000. Solve time grows with cost times this value."),
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
p class="text-sm text-neutral-700" {
(format!(
"Average solve: about {estimate:.1} s on a low-end Android phone, \
well under a second in desktop browsers."
))
}
(form_actions(html! {
(submit_button("Save"))
}))
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
(form_actions(html! {
(submit_button("Save Profile Timezone Configuration"))
}))
}
}
},
@@ -2409,33 +1856,22 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
fn rendered_voice_noise_suppression_section(
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> String {
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
#[test]
fn captcha_section_posts_the_switch_and_difficulty_fields() {
let markup =
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
assert!(markup.contains(r#"name="captcha_enabled""#));
assert!(markup.contains(r#"name="captcha_cost""#));
assert!(markup.contains(r#"name="captcha_max_counter""#));
assert!(markup.contains("about 3.6 s"));
}
#[test]
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
}],
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(markup.contains("1 of 200 stored"));
assert!(!markup.contains("at the cap"));
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
}
#[test]
@@ -2496,14 +1932,14 @@ mod tests {
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
fn domain_migration_section_flags_a_list_at_its_cap() {
let domain_migration = DomainMigrationConfigResponse {
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
..DomainMigrationConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
@@ -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" {
@@ -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<CurrentBan<'a>>,
}
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
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<MessageShredStatusResponse>>,
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(&note.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#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
}
#[test]
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let logs = vec![
entry(
"3",
"annotate_ban",
"Also sent links",
json!({"ban_audit_log_id": "2"}),
),
entry(
"2",
"temp_ban",
"Regel § 3",
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
),
entry(
"1",
"temp_ban",
"Older ban",
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
),
entry(
"4",
"annotate_ban",
"Old note",
json!({"ban_audit_log_id": "1"}),
),
];
let ban = find_current_ban(&target, &logs).expect("current ban");
assert_eq!(ban.entry.log_id, "2");
assert_eq!(
ban.notes
.iter()
.map(|note| note.log_id.as_str())
.collect::<Vec<_>>(),
["3"]
);
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
assert!(markup.contains("Regel § 3"));
assert!(markup.contains("Also sent links"));
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
assert!(markup.contains("?action=annotate_ban&amp;tab=moderation"));
}
}
@@ -9,7 +9,10 @@ use crate::{
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator,
timestamps::{format_admin_timestamp, snowflake_creation_date},
},
};
use maud::{Markup, html};
@@ -70,6 +73,22 @@ fn render_overview_tab(
@if let Some(reason) = &user.deletion_public_reason {
div class="mt-1" { "Public reason: " (reason) }
}
@if let Some(reason) = &user.deletion_audit_log_reason {
div class="mt-1" { "Private reason: " (reason) }
}
div class="mt-1" {
"Scheduled by "
@match user.deletion_scheduled_by.as_deref() {
Some(id) if id == user.id => { "the user" }
Some(id) => {
a href={(config.base_path) "/users/" (id)} class="underline" { (id) }
}
None => { "an unrecorded source" }
}
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
" on " (format_admin_timestamp(at))
}
}
}
}
}
@@ -293,7 +312,7 @@ fn flags_card(
))
@if user.phone_verification_deferred {
p class="text-sm text-amber-700 dark:text-amber-400" {
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
"Phone verification is deferred: the requirement above is stored but not enforced."
}
}
}
+22 -75
View File
@@ -37,6 +37,9 @@ fn deserialize_admin_users_me_response() {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["super_admin"],
"traits": ["beta_tester"],
"has_totp": true,
@@ -81,7 +84,8 @@ fn deserialize_flags_as_string_and_number() {
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
"suspicious_activity_flags": 0, "temp_banned_until": null,
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
"deletion_reason_code": null, "deletion_public_reason": null,
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
@@ -113,7 +117,8 @@ fn deserialize_discriminator_int_and_string() {
"premium_lifetime_sequence": null, "suspicious_activity_flags": 0,
"temp_banned_until": null, "pending_deletion_at": null,
"pending_bulk_message_deletion_at": null, "deletion_reason_code": null,
"deletion_public_reason": null, "acls": [], "traits": [],
"deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null, "acls": [], "traits": [],
"has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
@@ -170,6 +175,9 @@ fn deserialize_search_users_response() {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -392,25 +400,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"voice_e2ee_scope": "guild_feature_only",
"future_rollout_knob": 3
},
"voice_noise_suppression": {
"enabled": true,
"config_version": 4,
"default_backend": "rnnoise",
"enabled_backends": ["none", "standard", "rnnoise"],
"allow_user_override": true,
"rollout_basis_points": 10000,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
@@ -426,33 +415,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"],
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"altcha_captcha": {
"captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 0,
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
"max_counter": 1000,
"future_captcha_knob": 1
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -499,25 +473,11 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"youtube_enabled": true,
"bluesky_enabled": false
},
"services_available": {"gif": true, "youtube": true, "bluesky": false},
"deferred_phone_gate": {
"enabled": false,
"window_hours": 24,
"member_threshold": 100
}
"services_available": {"gif": true, "youtube": true, "bluesky": false}
},
"integrations": {
"gif": {"klipy_api_key_set": true, "effective_available": true},
"youtube": {"api_key_set": true, "effective_available": true},
"captcha": {
"provider": "hcaptcha",
"effective_provider": "hcaptcha",
"hcaptcha_site_key": "site",
"hcaptcha_secret_key_set": true,
"turnstile_site_key": "",
"turnstile_secret_key_set": false,
"effective_enabled": true
},
"email": {
"enabled": true,
"effective_enabled": true,
@@ -610,11 +570,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
assert!(!resp.self_hosted);
assert!(resp.voice_noise_suppression.enabled);
assert_eq!(resp.voice_noise_suppression.config_version, 4);
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert!(resp.domain_migration.enabled);
assert_eq!(resp.domain_migration.config_version, 2);
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
@@ -623,17 +578,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.altcha_captcha.enabled);
assert_eq!(resp.altcha_captcha.config_version, 3);
assert!(resp.altcha_captcha.anonymous_enabled);
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
assert_eq!(resp.altcha_captcha.max_counter, 10000);
assert!(resp.profile_timezone.enabled);
assert_eq!(resp.profile_timezone.config_version, 2);
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert!(resp.voice_noise_suppression.include_premium_users);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -920,7 +866,8 @@ fn deserialize_user_mutation_response() {
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
"suspicious_activity_flags": 0, "temp_banned_until": null,
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
"deletion_reason_code": null, "deletion_public_reason": null,
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
+5
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -273,6 +275,9 @@ fn admin_user() -> Value {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
+5 -22
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -464,7 +466,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
&[
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
@@ -955,6 +956,9 @@ fn user(id: &str, username: &str) -> Value {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
@@ -1179,27 +1183,6 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,
"default_backend": "standard",
"enabled_backends": [
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
],
"allow_user_override": true,
"rollout_basis_points": 0,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
@@ -31,6 +31,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["*"],
"traits": [],
"has_totp": true,
@@ -32,6 +32,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -32,6 +32,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -302,6 +304,9 @@ fn admin_user() -> Value {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
-1
View File
@@ -50,7 +50,6 @@
"@pkgs/nats": "workspace:*",
"@pkgs/postgres": "workspace:*",
"@pkgs/rate_limit": "workspace:*",
"@pkgs/sms": "workspace:*",
"@pkgs/virus_scan": "workspace:*",
"@pkgs/worker": "workspace:*",
"@simplewebauthn/server": "catalog:",
@@ -1,87 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import {HcaptchaProvider} from '@pkgs/captcha/src/providers/HcaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import type {RecaptchaProviderOptions} from '@pkgs/captcha/src/providers/RecaptchaProvider';
import {RecaptchaProvider} from '@pkgs/captcha/src/providers/RecaptchaProvider';
import {TestCaptchaProvider} from '@pkgs/captcha/src/providers/TestProvider';
import {TurnstileProvider} from '@pkgs/captcha/src/providers/TurnstileProvider';
import {UnavailableCaptchaProvider} from '@pkgs/captcha/src/providers/UnavailableCaptchaProvider';
interface BaseCaptchaProviderFactoryParams {
logger?: LoggerInterface;
}
interface CreateUnavailableCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'unavailable';
}
interface CreateTestCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'test';
}
interface CreateHcaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'hcaptcha';
secretKey: string;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CreateTurnstileProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'turnstile';
secretKey: string;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CreateRecaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'recaptcha';
secretKey: string;
minimumScore?: number;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
type CreateCaptchaProviderParams =
| CreateUnavailableCaptchaProviderParams
| CreateTestCaptchaProviderParams
| CreateHcaptchaProviderParams
| CreateTurnstileProviderParams
| CreateRecaptchaProviderParams;
function buildHttpOptions(
params: CreateHcaptchaProviderParams | CreateTurnstileProviderParams | CreateRecaptchaProviderParams,
): HttpCaptchaProviderOptions {
return {
secretKey: params.secretKey,
logger: params.logger,
timeoutMs: params.timeoutMs,
userAgent: params.userAgent,
fetchFn: params.fetchFn,
};
}
export function createCaptchaProvider(params: CreateCaptchaProviderParams): ICaptchaProvider {
if (params.mode === 'test') {
return new TestCaptchaProvider();
}
if (params.mode === 'hcaptcha') {
return new HcaptchaProvider(buildHttpOptions(params));
}
if (params.mode === 'turnstile') {
return new TurnstileProvider(buildHttpOptions(params));
}
if (params.mode === 'recaptcha') {
const options: RecaptchaProviderOptions = {
...buildHttpOptions(params),
minimumScore: params.minimumScore,
};
return new RecaptchaProvider(options);
}
return new UnavailableCaptchaProvider();
}
@@ -1,13 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface VerifyCaptchaParams {
token: string;
remoteIp?: string;
}
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
verify(params: VerifyCaptchaParams): Promise<boolean>;
}
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
const HEX_PATTERN = /^[0-9a-f]+$/u;
@@ -56,8 +55,7 @@ function decodePayload(token: string): AltchaPayload | null {
}
}
export class AltchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
export class AltchaProvider {
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
@@ -79,7 +77,7 @@ export class AltchaProvider implements ICaptchaProvider {
});
}
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
async verify({token}: {token: string}): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class HcaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'hcaptcha';
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
protected readonly providerName = 'hCaptcha';
}
@@ -1,105 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {ms} from 'itty-time';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
const DEFAULT_TIMEOUT = ms('10 seconds');
export interface HttpCaptchaProviderOptions {
secretKey: string;
logger?: LoggerInterface;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
hostname?: string;
challenge_ts?: string;
score?: number;
}
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const data = value as Record<string, unknown>;
const errorCodes = data['error-codes'];
return (
typeof data.success === 'boolean' &&
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
(data.hostname === undefined || typeof data.hostname === 'string') &&
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
(data.score === undefined ||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
);
}
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
abstract readonly type: CaptchaProviderType;
protected readonly secretKey: string;
protected readonly logger: LoggerInterface | undefined;
protected readonly timeoutMs: number;
protected readonly userAgent: string;
protected readonly fetchFn: typeof fetch;
protected abstract readonly verifyUrl: string;
protected abstract readonly providerName: string;
constructor(options: HttpCaptchaProviderOptions) {
this.secretKey = options.secretKey;
this.logger = options.logger;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.fetchFn = options.fetchFn ?? fetch;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const body = new URLSearchParams();
body.append('secret', this.secretKey);
body.append('response', token);
if (remoteIp) {
body.append('remoteip', remoteIp);
}
const response = await this.fetchFn(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent,
},
body: body.toString(),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
});
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
return false;
}
const data: unknown = await response.json();
if (!isCaptchaVerifyResponse(data)) {
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
return false;
}
if (!data.success) {
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
return false;
}
return this.validateResponse(data);
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
} else {
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
}
return false;
}
}
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
return true;
}
}
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
const DEFAULT_MINIMUM_SCORE = 0.5;
interface RecaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
score?: number;
}
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
minimumScore?: number;
}
export class RecaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'recaptcha';
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
protected readonly providerName = 'reCAPTCHA';
private readonly minimumScore: number;
constructor(options: RecaptchaProviderOptions) {
super(options);
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
}
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
if (data.score !== undefined && data.score < this.minimumScore) {
this.logger?.warn(
{score: data.score, minimumScore: this.minimumScore},
'reCAPTCHA score below minimum threshold',
);
return false;
}
return true;
}
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class TestCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'test';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class TurnstileProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'turnstile';
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
protected readonly providerName = 'Turnstile';
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class UnavailableCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'unavailable';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -85,7 +85,7 @@ export function createCassandraIpInfoRequestAuditLogger(
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.riskNote,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
+1 -113
View File
@@ -2,7 +2,7 @@
import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting';
import {getSameIpDecisionKey, isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
import maxmind, {type AsnResponse, type CityResponse, type Reader} from 'maxmind';
import maxmind, {type CityResponse, type Reader} from 'maxmind';
export interface GeoipResult {
countryCode: string | null;
@@ -17,33 +17,17 @@ export interface GeoipResult {
timeZone?: string | null;
}
export interface GeoipAsnResult {
normalizedIp: string | null;
asn: number | null;
asnOrg: string | null;
available: boolean;
}
type CacheEntry = {
result: GeoipResult;
expiresAt: number;
};
type AsnCacheEntry = {
result: GeoipAsnResult;
expiresAt: number;
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
const asnCache = new Map<string, AsnCacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
let maxmindAsnReader: Reader<AsnResponse> | null = null;
let maxmindAsnReaderPromise: Promise<Reader<AsnResponse>> | null = null;
let maxmindAsnUnavailable = false;
function buildFallbackResult(normalizedIp: string): GeoipResult {
return {
@@ -60,15 +44,6 @@ function buildFallbackResult(normalizedIp: string): GeoipResult {
};
}
function buildAsnFallbackResult(normalizedIp: string | null): GeoipAsnResult {
return {
normalizedIp: normalizedIp || null,
asn: null,
asnOrg: null,
available: false,
};
}
async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
@@ -86,24 +61,6 @@ async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
return maxmindReaderPromise;
}
async function ensureAsnReader(dbPath: string): Promise<Reader<AsnResponse>> {
if (maxmindAsnReader) return maxmindAsnReader;
if (!maxmindAsnReaderPromise) {
maxmindAsnReaderPromise = maxmind
.open<AsnResponse>(dbPath, {watchForUpdates: true, watchForUpdatesNonPersistent: true})
.then((reader) => {
maxmindAsnReader = reader;
return reader;
})
.catch((error) => {
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = true;
throw error;
});
}
return maxmindAsnReaderPromise;
}
function stateLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
@@ -157,31 +114,6 @@ function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
function getCachedAsnResult(cacheKey: string, normalizedIp: string): GeoipAsnResult | null {
const cached = asnCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
asnCache.delete(cacheKey);
return null;
}
asnCache.delete(cacheKey);
asnCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedAsnResult(cacheKey: string, result: GeoipAsnResult): void {
asnCache.delete(cacheKey);
if (asnCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = asnCache.keys().next().value;
if (oldestKey !== undefined) {
asnCache.delete(oldestKey);
}
}
asnCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -206,24 +138,6 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
}
}
async function lookupMaxmindAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
try {
const reader = await ensureAsnReader(dbPath);
const record = reader.get(clean);
if (!record) {
return {normalizedIp: clean, asn: null, asnOrg: null, available: true};
}
return {
normalizedIp: clean,
asn: record.autonomous_system_number ?? null,
asnOrg: record.autonomous_system_organization ?? null,
available: true,
};
} catch {
return buildAsnFallbackResult(clean);
}
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedGeoipResult(cacheKey, clean);
@@ -235,17 +149,6 @@ async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult>
return result;
}
async function resolveAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedAsnResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmindAsn(clean, dbPath);
setCachedAsnResult(cacheKey, result);
return result;
}
export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): Promise<GeoipResult> {
if (!dbPath) {
return buildFallbackResult(ip);
@@ -257,25 +160,10 @@ export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): P
return resolveGeoip(clean, dbPath);
}
export async function lookupAsnByIp(ip: string, asnDbPath: string | undefined): Promise<GeoipAsnResult> {
if (!asnDbPath || maxmindAsnUnavailable) {
return buildAsnFallbackResult(null);
}
const clean = normalizeIpString(ip);
if (!isValidIp(clean)) {
return buildAsnFallbackResult(clean);
}
return resolveAsn(clean, asnDbPath);
}
export function resetGeoipReadersForTesting(): void {
maxmindReader = null;
maxmindReaderPromise = null;
maxmindAsnReader = null;
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = false;
geoipCache.clear();
asnCache.clear();
}
export function formatGeoipLocation(result: GeoipResult, locale?: string | null): string | null {
+3 -49
View File
@@ -9,26 +9,20 @@ import {pipeline} from 'node:stream/promises';
import {GetObjectCommand, S3Client} from '@aws-sdk/client-s3';
const GEOIP_DOWNLOAD_PATH_QUERY_PARAM = 'download_path';
const GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM = 'asn_download_path';
const GEOIP_ASN_KEY_QUERY_PARAM = 'asn_key';
const DEFAULT_GEOIP_TEMPORARY_DIRECTORY = '/tmp/fluxer/geoip';
const DEFAULT_GEOIP_ASN_DB_BASENAME = 'GeoLite2-ASN.mmdb';
type GeoipSourceMode = 'filesystem' | 's3';
interface GeoipFilesystemSourceConfig {
mode: 'filesystem';
maxmindDbPath?: string;
maxmindAsnDbPath?: string;
}
interface GeoipS3SourceConfig {
mode: 's3';
maxmindDbPath: string;
maxmindAsnDbPath?: string;
s3Bucket: string;
s3Key: string;
s3AsnKey?: string;
}
type GeoipSourceConfig = GeoipFilesystemSourceConfig | GeoipS3SourceConfig;
@@ -50,7 +44,6 @@ interface GeoipStartupResult {
mode: GeoipSourceMode;
downloaded: boolean;
city?: GeoipDownloadedDatabase;
asn?: GeoipDownloadedDatabase;
maxmindDbPath?: string;
bucket?: string;
key?: string;
@@ -83,14 +76,9 @@ export function resolveGeoipRuntimeSourceConfig(
const temporaryDirectory = options.temporaryDirectory ?? DEFAULT_GEOIP_TEMPORARY_DIRECTORY;
requireGeoipRuntimePathOptions(options.serviceName, temporaryDirectory);
const serviceDir = path.join(temporaryDirectory, options.serviceName);
const resolvedCityPath = path.join(serviceDir, path.basename(sourceConfig.maxmindDbPath));
const resolvedAsnPath = sourceConfig.s3AsnKey
? path.join(serviceDir, path.basename(sourceConfig.maxmindAsnDbPath ?? sourceConfig.s3AsnKey))
: sourceConfig.maxmindAsnDbPath;
return {
...sourceConfig,
maxmindDbPath: resolvedCityPath,
maxmindAsnDbPath: resolvedAsnPath,
maxmindDbPath: path.join(serviceDir, path.basename(sourceConfig.maxmindDbPath)),
};
}
@@ -112,9 +100,6 @@ async function ensureS3Startup(
): Promise<GeoipStartupResult> {
const resolvedS3Config = requireGeoipS3ConnectionConfig(s3Config);
await fs.mkdir(path.dirname(geoip.maxmindDbPath), {recursive: true});
if (geoip.maxmindAsnDbPath) {
await fs.mkdir(path.dirname(geoip.maxmindAsnDbPath), {recursive: true});
}
const client = new S3Client({
endpoint: resolvedS3Config.endpoint,
region: resolvedS3Config.region,
@@ -128,15 +113,10 @@ async function ensureS3Startup(
});
try {
const city = await downloadS3Object(client, geoip.s3Bucket, geoip.s3Key, geoip.maxmindDbPath);
let asn: GeoipDownloadedDatabase | undefined;
if (geoip.s3AsnKey && geoip.maxmindAsnDbPath) {
asn = await downloadS3Object(client, geoip.s3Bucket, geoip.s3AsnKey, geoip.maxmindAsnDbPath);
}
return {
mode: 's3',
downloaded: true,
city,
asn,
maxmindDbPath: geoip.maxmindDbPath,
bucket: geoip.s3Bucket,
key: geoip.s3Key,
@@ -168,11 +148,9 @@ async function downloadS3Object(
}
function createGeoipFilesystemSourceConfig(rawValue: string | undefined): GeoipFilesystemSourceConfig {
const maxmindDbPath = rawValue === '' ? undefined : rawValue;
return {
mode: 'filesystem',
maxmindDbPath,
maxmindAsnDbPath: maxmindDbPath ? path.join(path.dirname(maxmindDbPath), DEFAULT_GEOIP_ASN_DB_BASENAME) : undefined,
maxmindDbPath: rawValue === '' ? undefined : rawValue,
};
}
@@ -186,15 +164,11 @@ function parseGeoipS3SourceConfig(rawValue: string): GeoipS3SourceConfig {
if (!s3Key) {
throw new Error(`Invalid GeoIP S3 URL (missing object key): ${rawValue}`);
}
const maxmindDbPath = resolveGeoipDownloadPath(sourceUrl, rawValue);
const {s3AsnKey, maxmindAsnDbPath} = resolveGeoipAsnPaths(sourceUrl, maxmindDbPath, rawValue);
return {
mode: 's3',
maxmindDbPath,
maxmindAsnDbPath,
maxmindDbPath: resolveGeoipDownloadPath(sourceUrl, rawValue),
s3Bucket,
s3Key,
s3AsnKey,
};
}
@@ -211,26 +185,6 @@ function resolveGeoipDownloadPath(sourceUrl: URL, rawValue: string): string {
return configuredDownloadPath;
}
function resolveGeoipAsnPaths(
sourceUrl: URL,
cityDownloadPath: string,
rawValue: string,
): {
s3AsnKey?: string;
maxmindAsnDbPath?: string;
} {
const asnKey = sourceUrl.searchParams.get(GEOIP_ASN_KEY_QUERY_PARAM) ?? undefined;
if (!asnKey) return {};
const explicitAsnDownloadPath = sourceUrl.searchParams.get(GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM);
if (explicitAsnDownloadPath && !path.isAbsolute(explicitAsnDownloadPath)) {
throw new Error(
`GeoIP S3 URL query parameter "${GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM}" must be an absolute path: ${rawValue}`,
);
}
const maxmindAsnDbPath = explicitAsnDownloadPath ?? path.join(path.dirname(cityDownloadPath), path.basename(asnKey));
return {s3AsnKey: asnKey, maxmindAsnDbPath};
}
function requireGeoipS3ConnectionConfig(s3Config: GeoipS3ConnectionConfig | undefined): GeoipS3ConnectionConfig {
if (!s3Config) {
throw new Error('GeoIP is configured for S3 mode, but S3 configuration is missing.');
+14 -41
View File
@@ -13,7 +13,6 @@ const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
const FAILURE_TTL_BACKGROUND_CAP_SECONDS = 120;
export interface IpInfoGeoBlock {
countryCode: string | null;
@@ -65,7 +64,7 @@ export interface IpInfoFlags {
export interface IpInfoLookupResult {
ip: string;
available: boolean;
riskNote: string;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
@@ -78,12 +77,6 @@ export interface IpInfoCache {
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export type IpInfoLookupPriority = 'critical' | 'standard' | 'background';
export interface IpInfoLookupBudget {
tryConsume(priority: IpInfoLookupPriority): Promise<boolean>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
@@ -91,26 +84,15 @@ export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedAtMs: number;
}
export function resolveIpInfoLookupPriority(source: string | undefined): IpInfoLookupPriority {
if (source === 'admin.ip_ban' || source === 'admin.scheduled_deletion_suspicious_ip') return 'critical';
if (source === 'AbusiveIpAutoBanner') return 'background';
return 'standard';
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(
outcome: CachedIpInfoFailure['failureOutcome'],
httpStatus: number | null,
priority: IpInfoLookupPriority,
): number {
let ttl = FAILURE_TTL_HTTP_ERROR_SECONDS;
if (outcome === 'request_failed') ttl = FAILURE_TTL_REQUEST_FAILED_SECONDS;
else if (outcome === 'schema_mismatch') ttl = FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
else if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) ttl = FAILURE_TTL_QUOTA_SECONDS;
return priority === 'background' ? Math.min(ttl, FAILURE_TTL_BACKGROUND_CAP_SECONDS) : ttl;
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
@@ -126,10 +108,10 @@ export interface IpInfoRequestAuditEvent {
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch' | 'budget_shed';
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
riskNote: string;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
@@ -150,7 +132,6 @@ interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
budget?: IpInfoLookupBudget;
}
export interface IpInfoService {
@@ -218,11 +199,10 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const priority = resolveIpInfoLookupPriority(context?.source);
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.riskNote);
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
@@ -251,7 +231,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
riskNote: params.result.riskNote,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
@@ -267,13 +247,6 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
if (ctx.budget && !(await ctx.budget.tryConsume(priority))) {
return finalize({
result: unavailable(ip, `IPInfo lookup shed (budget exhausted, priority: ${priority})`),
outcome: 'budget_shed',
httpStatus: null,
});
}
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
@@ -287,7 +260,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus, priority))
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
@@ -361,7 +334,7 @@ function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
riskNote: reason,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
@@ -425,7 +398,7 @@ function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
return {
ip: raw.ip,
available: true,
riskNote: buildRiskNote(isAnonymous, anon),
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
@@ -486,7 +459,7 @@ function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
};
}
function buildRiskNote(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
@@ -118,7 +118,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.riskNote,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
@@ -74,6 +74,8 @@ export interface IKVProvider {
rpush(key: string, ...values: Array<string>): Promise<number>;
lpop(key: string, count?: number): Promise<Array<string>>;
llen(key: string): Promise<number>;
lrange(key: string, start: number, stop: number): Promise<Array<string>>;
ltrim(key: string, start: number, stop: number): Promise<void>;
hset(key: string, field: string, value: string): Promise<number>;
hdel(key: string, ...fields: Array<string>): Promise<number>;
hget(key: string, field: string): Promise<string | null>;
@@ -555,6 +555,14 @@ export class KVClient implements IKVProvider {
return await this.execute('llen', async () => this.client.llen(key));
}
async lrange(key: string, start: number, stop: number): Promise<Array<string>> {
return await this.execute('lrange', async () => this.client.lrange(key, start, stop));
}
async ltrim(key: string, start: number, stop: number): Promise<void> {
await this.execute('ltrim', async () => this.client.ltrim(key, start, stop));
}
async hset(key: string, field: string, value: string): Promise<number> {
return await this.execute('hset', async () => this.client.hset(key, field, value));
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@pkgs/sms",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"@fluxer/errors": "workspace:*",
"@fluxer/logger": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
-14
View File
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export interface ISmsService {
startVerification(phone: string): Promise<void>;
startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult>;
checkVerification(phone: string, code: string): Promise<boolean>;
lookupPhone(phone: string): Promise<PhoneLookupResult | null>;
}
@@ -1,64 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type PhoneLineType =
| 'mobile'
| 'landline'
| 'fixedVoip'
| 'nonFixedVoip'
| 'personal'
| 'tollFree'
| 'premium'
| 'sharedCost'
| 'uan'
| 'voicemail'
| 'pager'
| 'unknown';
export interface PhoneLookupResult {
valid: boolean;
lineType: PhoneLineType | null;
countryCode: string | null;
carrierName: string | null;
smsPumpingRiskScore: number | null;
}
export const ACCEPTED_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>(['mobile', 'personal']);
export const VOIP_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>(['fixedVoip', 'nonFixedVoip']);
export const HARD_REJECT_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
'landline',
'tollFree',
'premium',
'sharedCost',
'uan',
'voicemail',
'pager',
]);
const SMS_PUMPING_RISK_THRESHOLDS: Readonly<Record<string, number>> = {
US: 100,
CA: 100,
GB: 70,
DE: 70,
FR: 70,
IT: 70,
ES: 70,
NL: 70,
SE: 70,
NO: 70,
DK: 70,
FI: 70,
AU: 70,
NZ: 70,
JP: 70,
KR: 70,
CH: 70,
AT: 70,
BE: 70,
IE: 70,
PT: 70,
};
const DEFAULT_SMS_PUMPING_RISK_THRESHOLD = 35;
export function getSmsPumpingRiskThreshold(countryCode: string | null): number {
if (countryCode === null) return DEFAULT_SMS_PUMPING_RISK_THRESHOLD;
return SMS_PUMPING_RISK_THRESHOLDS[countryCode] ?? DEFAULT_SMS_PUMPING_RISK_THRESHOLD;
}
-34
View File
@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ISmsService} from '@pkgs/sms/src/ISmsService';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export class SmsService implements ISmsService {
private readonly provider: ISmsProvider;
constructor(provider: ISmsProvider = new UnavailableSmsProvider()) {
this.provider = provider;
}
async startVerification(phone: string): Promise<void> {
await this.provider.startVerification(phone);
}
async startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult> {
return this.provider.startVerificationWithResult(phone, options);
}
async checkVerification(phone: string, code: string): Promise<boolean> {
return this.provider.checkVerification(phone, code);
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
return this.provider.lookupPhone(phone);
}
}
@@ -1,17 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type SmsVerificationStartChannel = 'sms' | 'auto';
export interface SmsVerificationStartOptions {
channel?: SmsVerificationStartChannel;
deviceIp?: string;
rateLimits?: Record<string, string>;
}
export interface SmsVerificationStartResult {
channel: string;
}
export const SMS_VERIFICATION_START_SMS_RESULT: SmsVerificationStartResult = {
channel: 'sms',
};
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SMS_MASK_VISIBLE_PREFIX_LENGTH} from '@fluxer/constants/src/SmsVerificationConstants';
export function maskPhoneNumber(phone: string): string {
if (phone.length <= SMS_MASK_VISIBLE_PREFIX_LENGTH) {
return `${phone}***`;
}
return `${phone.slice(0, SMS_MASK_VISIBLE_PREFIX_LENGTH)}***`;
}
@@ -1,100 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
import {SmsService} from '@pkgs/sms/src/SmsService';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {describe, expect, it} from 'vitest';
function createInMemoryProvider(): ISmsProvider & {
verifications: Map<string, string>;
startedVerifications: Array<string>;
} {
const verifications = new Map<string, string>();
const startedVerifications: Array<string> = [];
return {
verifications,
startedVerifications,
async startVerification(phone: string): Promise<void> {
startedVerifications.push(phone);
verifications.set(phone, '123456');
},
async startVerificationWithResult(phone: string): Promise<SmsVerificationStartResult> {
startedVerifications.push(phone);
verifications.set(phone, '123456');
return SMS_VERIFICATION_START_SMS_RESULT;
},
async checkVerification(phone: string, code: string): Promise<boolean> {
const storedCode = verifications.get(phone);
if (storedCode === code) {
verifications.delete(phone);
return true;
}
return false;
},
async lookupPhone(_phone: string): Promise<PhoneLookupResult | null> {
return null;
},
};
}
describe('SmsService', () => {
describe('with provider', () => {
it('starts verification through provider', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
expect(provider.startedVerifications).toContain('+15551234567');
expect(provider.verifications.has('+15551234567')).toBe(true);
});
it('checks verification through provider and returns true for valid code', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
const code = provider.verifications.get('+15551234567') ?? '';
const result = await service.checkVerification('+15551234567', code);
expect(result).toBe(true);
});
it('checks verification through provider and returns false for invalid code', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
const result = await service.checkVerification('+15551234567', 'wrong-code');
expect(result).toBe(false);
});
it('returns false for verification check on non-existent phone', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
const result = await service.checkVerification('+15559999999', '123456');
expect(result).toBe(false);
});
});
describe('with unavailable provider', () => {
it('silently completes startVerification when provider is unavailable', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.startVerification('+15551234567')).resolves.toBeUndefined();
});
it('throws SmsVerificationUnavailableError when checking verification', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.checkVerification('+15551234567', '123456')).rejects.toThrow(
SmsVerificationUnavailableError,
);
});
it('defaults to unavailable provider when no provider is injected', async () => {
const service = new SmsService();
await expect(service.checkVerification('+15551234567', '123456')).rejects.toThrow(
SmsVerificationUnavailableError,
);
});
it('exposes the correct api error code when checking verification', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.checkVerification('+15551234567', '123456')).rejects.toMatchObject({
code: APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE,
message: APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE,
});
});
});
});
@@ -1,369 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {createMockLogger} from '@fluxer/logger/src/mock';
import {TwilioSmsProvider, TwilioVerificationRateLimitError} from '@pkgs/sms/src/providers/TwilioSmsProvider';
import {describe, expect, it} from 'vitest';
interface TwilioRequest {
url: string;
authHeader: string;
body: string;
}
interface TwilioLookupRequest {
url: string;
method: string | undefined;
authHeader: string;
}
function getCapturedRequest(request: TwilioRequest | null): TwilioRequest {
if (!request) {
throw new Error('Expected Twilio request to be captured');
}
return request;
}
function getCapturedLookupRequest(request: TwilioLookupRequest | null): TwilioLookupRequest {
if (!request) {
throw new Error('Expected Twilio lookup request to be captured');
}
return request;
}
describe('TwilioSmsProvider', () => {
it('calls Twilio Verify start endpoint with expected payload', async () => {
let capturedRequest: TwilioRequest | null = null;
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({success: true}), {status: 200});
};
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: fetchStub,
});
const phone = '+15551234567';
await provider.startVerification(phone);
const request = getCapturedRequest(capturedRequest);
expect(request.url).toBe('https://verify.twilio.com/v2/Services/VA123/Verifications');
expect(request.authHeader).toBe(`Basic ${Buffer.from('AC123:twilio-secret').toString('base64')}`);
expect(request.body).toContain('To=%2B15551234567');
expect(request.body).toContain('Channel=sms');
});
it('can start verification with auto channel, device IP, and programmable rate limits', async () => {
let capturedRequest: TwilioRequest | null = null;
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({channel: 'auto'}), {status: 200});
};
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: fetchStub,
});
const result = await provider.startVerificationWithResult('+15551234567', {
channel: 'auto',
deviceIp: '203.0.113.10',
rateLimits: {
fluxer_user_id: '123',
fluxer_phone_prefix: '+1555',
},
});
const request = getCapturedRequest(capturedRequest);
expect(result).toEqual({channel: 'auto'});
expect(request.body).toContain('Channel=auto');
expect(request.body).toContain('DeviceIp=203.0.113.10');
expect(request.body).toContain('RateLimits%5Bfluxer_user_id%5D=123');
expect(request.body).toContain('RateLimits%5Bfluxer_phone_prefix%5D=%2B1555');
});
it('returns true when verification check is approved', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({status: 'approved'}), {status: 200}),
});
const result = await provider.checkVerification('+15551234567', '123456');
expect(result).toBe(true);
});
it('returns false when verification check is rejected', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({status: 'pending'}), {status: 200}),
});
expect(await provider.checkVerification('+15551234567', '123456')).toBe(false);
});
it('throws InvalidPhoneNumberError for Twilio invalid phone code', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 21211, message: 'Invalid To phone number'}), {status: 400}),
});
await expect(provider.startVerification('+15550000000')).rejects.toThrow(InvalidPhoneNumberError);
});
it('throws TwilioVerificationRateLimitError for Twilio max-send-attempt responses', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60203, message: 'Max send attempts reached'}), {status: 403}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Too many verification texts were sent recently. Try again later.',
twilioStatus: 403,
twilioCode: 60203,
cooldownScope: 'phone',
cooldownMs: 600000,
});
});
it('throws TwilioVerificationRateLimitError for Fraud Guard blocks', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60410, message: 'Blocked by Verify Fraud Guard'}), {status: 403}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Phone verification is temporarily blocked for this destination. Try again later.',
twilioStatus: 403,
twilioCode: 60410,
cooldownScope: 'phone',
cooldownMs: 43200000,
});
});
it('throws SmsVerificationUnavailableError for unexpected non-OK start responses', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({code: 30001, message: 'Queue overflow'}), {status: 503}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(SmsVerificationUnavailableError);
});
it('throws SmsVerificationUnavailableError when start verification request fails before a response', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => {
throw new TypeError('Failed to fetch');
},
});
await expect(provider.startVerification('+15551234567')).rejects.toThrow(SmsVerificationUnavailableError);
});
it('throws TwilioVerificationRateLimitError for max verification-check attempts', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60202, message: 'Max check attempts reached'}), {status: 429}),
});
const error = await provider.checkVerification('+15551234567', '123456').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Too many verification code checks were attempted. Request a new code later.',
twilioStatus: 429,
twilioCode: 60202,
cooldownScope: 'phone',
});
});
describe('lookupPhone', () => {
const CONFIG = {accountSid: 'AC123', authToken: 'twilio-secret', verifyServiceSid: 'VA123'};
it('sends a GET to Lookup v2 with Fields=line_type_intelligence and Basic auth', async () => {
let capturedRequest: TwilioLookupRequest | null = null;
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async (input, init) => {
capturedRequest = {
url: String(input),
method: init?.method,
authHeader: (init?.headers as Record<string, string>)?.Authorization,
};
return new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'mobile', carrier_name: 'T-Mobile USA'},
}),
{status: 200},
);
},
});
const result = await provider.lookupPhone('+15551234567');
const request = getCapturedLookupRequest(capturedRequest);
expect(request.method).toBe('GET');
expect(request.url).toBe(
'https://lookups.twilio.com/v2/PhoneNumbers/%2B15551234567?Fields=line_type_intelligence,sms_pumping_risk',
);
expect(request.authHeader).toBe(`Basic ${Buffer.from('AC123:twilio-secret').toString('base64')}`);
expect(result).toEqual({
valid: true,
lineType: 'mobile',
countryCode: 'US',
carrierName: 'T-Mobile USA',
smsPumpingRiskScore: null,
});
});
it('returns nonFixedVoip for Twilio virtual-number responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'NL',
line_type_intelligence: {type: 'nonFixedVoip', carrier_name: 'Twilio LLC'},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+3197058046509');
expect(result?.valid).toBe(true);
expect(result?.lineType).toBe('nonFixedVoip');
});
it('returns personal for +31970-style Dutch personal-number responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'NL',
line_type_intelligence: {type: 'personal', carrier_name: null},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+31970123456');
expect(result?.lineType).toBe('personal');
});
it('returns valid=false on 404 (phone not found)', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () => new Response('', {status: 404}),
});
const result = await provider.lookupPhone('+15550000000');
expect(result).toEqual({
valid: false,
lineType: null,
countryCode: null,
carrierName: null,
smsPumpingRiskScore: null,
});
});
it('returns null (fail-closed trigger) on non-OK non-404 responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 20003, message: 'Authentication error'}), {status: 401}),
});
const result = await provider.lookupPhone('+15551234567');
expect(result).toBeNull();
});
it('returns null on network failure', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () => {
throw new TypeError('Failed to fetch');
},
});
const result = await provider.lookupPhone('+15551234567');
expect(result).toBeNull();
});
it('normalizes unknown Twilio line-type values to "unknown"', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'someBrandNewTwilioType', carrier_name: 'Test'},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+15551234567');
expect(result?.lineType).toBe('unknown');
});
it('parses sms_pumping_risk_score when present', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'mobile', carrier_name: 'T-Mobile USA'},
sms_pumping_risk: {sms_pumping_risk_score: 72},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+15551234567');
expect(result?.smsPumpingRiskScore).toBe(72);
});
});
});
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export interface ISmsProvider {
startVerification(phone: string): Promise<void>;
startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult>;
checkVerification(phone: string, code: string): Promise<boolean>;
lookupPhone(phone: string): Promise<PhoneLookupResult | null>;
}
@@ -1,48 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {TestSmsProvider} from '@pkgs/sms/src/providers/TestSmsProvider';
import {TwilioSmsProvider, type TwilioSmsProviderConfig} from '@pkgs/sms/src/providers/TwilioSmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
interface BaseSmsProviderFactoryParams {
logger?: LoggerInterface;
}
interface CreateUnavailableSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'unavailable';
}
interface CreateTestSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'test';
verificationCode?: string;
}
interface CreateTwilioSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'twilio';
config: TwilioSmsProviderConfig;
fetchFn?: typeof fetch;
}
type CreateSmsProviderParams =
| CreateUnavailableSmsProviderParams
| CreateTestSmsProviderParams
| CreateTwilioSmsProviderParams;
export function createSmsProvider(params: CreateSmsProviderParams): ISmsProvider {
if (params.mode === 'test') {
return new TestSmsProvider({
logger: params.logger,
verificationCode: params.verificationCode,
});
}
if (params.mode === 'twilio') {
return new TwilioSmsProvider({
config: params.config,
logger: params.logger,
fetchFn: params.fetchFn,
});
}
return new UnavailableSmsProvider();
}
@@ -1,54 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SMS_TEST_VERIFICATION_CODE} from '@fluxer/constants/src/SmsVerificationConstants';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {maskPhoneNumber} from '@pkgs/sms/src/SmsVerificationUtils';
interface TestSmsProviderOptions {
logger?: LoggerInterface;
verificationCode?: string;
}
export class TestSmsProvider implements ISmsProvider {
private readonly logger: LoggerInterface;
private readonly verificationCode: string;
constructor({logger, verificationCode}: TestSmsProviderOptions = {}) {
this.logger = logger ?? createLogger('@pkgs/sms/src', {environment: 'test'});
this.verificationCode = verificationCode ?? SMS_TEST_VERIFICATION_CODE;
}
async startVerification(phone: string): Promise<void> {
this.logger.info(
`[TestSmsProvider] Mock verification started for ${maskPhoneNumber(phone)}. Use code: ${this.verificationCode}`,
);
}
async startVerificationWithResult(phone: string): Promise<SmsVerificationStartResult> {
await this.startVerification(phone);
return SMS_VERIFICATION_START_SMS_RESULT;
}
async checkVerification(phone: string, code: string): Promise<boolean> {
const isValid = code === this.verificationCode;
this.logger.info(
`[TestSmsProvider] Mock verification check for ${maskPhoneNumber(phone)} with code ${code}: ${isValid ? 'APPROVED' : 'REJECTED'}`,
);
return isValid;
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
this.logger.info(`[TestSmsProvider] Mock lookup for ${maskPhoneNumber(phone)} -> valid mobile`);
return {
valid: true,
lineType: 'mobile',
countryCode: null,
carrierName: 'Test Carrier',
smsPumpingRiskScore: null,
};
}
}
@@ -1,512 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
SMS_TWILIO_DEFAULT_LOOKUP_API_URL,
SMS_TWILIO_DEFAULT_VERIFY_API_URL,
} from '@fluxer/constants/src/SmsVerificationConstants';
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {PhoneLineType, PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {maskPhoneNumber} from '@pkgs/sms/src/SmsVerificationUtils';
const TWILIO_INVALID_PHONE_ERROR_CODE = 21211;
const TWILIO_TOO_MANY_REQUESTS_ERROR_CODE = 20429;
const TWILIO_MAX_SEND_ATTEMPTS_ERROR_CODE = 60203;
const TWILIO_MAX_CHECK_ATTEMPTS_ERROR_CODE = 60202;
const TWILIO_CONCURRENT_REQUESTS_ERROR_CODE = 60212;
const TWILIO_FRAUD_GUARD_BLOCK_ERROR_CODE = 60410;
const TWILIO_FRAUD_PREVENTION_BLOCK_ERROR_CODE = 60412;
const TWILIO_DEFAULT_BUSY_RETRY_AFTER_SECONDS = 60;
const TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS = 10 * 60;
const TWILIO_FRAUD_BLOCK_RETRY_AFTER_SECONDS = 12 * 60 * 60;
interface TwilioErrorResponse {
code?: number;
message?: string;
}
interface TwilioResponse {
ok: boolean;
status: number;
body: unknown;
}
type TwilioCooldownScope = 'account' | 'phone' | 'account_and_phone';
interface StartVerificationSentryContext extends Record<string, unknown> {
smsProvider: 'twilio';
smsOperation: 'start_verification';
twilioEndpoint: 'Verifications';
phone: string;
twilioStatus?: number;
twilioCode?: number;
twilioMessage?: string;
twilioRequestError?: string;
}
interface TwilioLookupV2Response {
valid: boolean;
country_code?: string | null;
line_type_intelligence?: {
type?: string | null;
carrier_name?: string | null;
error_code?: number | null;
} | null;
sms_pumping_risk?: {
sms_pumping_risk_score?: number | null;
error_code?: number | null;
} | null;
}
export interface TwilioSmsProviderConfig {
accountSid: string;
authToken: string;
verifyServiceSid: string;
verifyApiUrl?: string;
lookupApiUrl?: string;
lookupTimeoutMs?: number;
}
interface TwilioSmsProviderDependencies {
config: TwilioSmsProviderConfig;
logger?: LoggerInterface;
fetchFn?: typeof fetch;
}
const DEFAULT_LOOKUP_TIMEOUT_MS = 3000;
const VERIFY_TIMEOUT_MS = 10000;
const KNOWN_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
'mobile',
'landline',
'fixedVoip',
'nonFixedVoip',
'personal',
'tollFree',
'premium',
'sharedCost',
'uan',
'voicemail',
'pager',
'unknown',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNullishString(value: unknown): value is string | null | undefined {
return value == null || typeof value === 'string';
}
function isNullishInteger(value: unknown): value is number | null | undefined {
return value == null || (typeof value === 'number' && Number.isSafeInteger(value));
}
function isTwilioLookupResponse(value: unknown): value is TwilioLookupV2Response {
if (!isRecord(value) || typeof value.valid !== 'boolean' || !isNullishString(value.country_code)) return false;
const line = value.line_type_intelligence;
if (
line != null &&
(!isRecord(line) ||
!isNullishString(line.type) ||
!isNullishString(line.carrier_name) ||
!isNullishInteger(line.error_code))
) {
return false;
}
const risk = value.sms_pumping_risk;
if (risk == null) return true;
if (!isRecord(risk) || !isNullishInteger(risk.error_code)) return false;
const score = risk.sms_pumping_risk_score;
return isNullishInteger(score) && (score == null || (score >= 0 && score <= 100));
}
function parseTwilioError(value: unknown): TwilioErrorResponse | null {
if (!isRecord(value)) return null;
if (value.code !== undefined && (typeof value.code !== 'number' || !Number.isSafeInteger(value.code))) return null;
if (value.message !== undefined && typeof value.message !== 'string') return null;
return {code: value.code, message: value.message};
}
function normalizeLineType(raw: string | null | undefined): PhoneLineType | null {
if (!raw) return null;
return KNOWN_LINE_TYPES.has(raw as PhoneLineType) ? (raw as PhoneLineType) : 'unknown';
}
export class SmsVerificationStartError extends Error {
readonly sentryContext: StartVerificationSentryContext;
constructor(sentryContext: StartVerificationSentryContext, options?: ErrorOptions) {
super('Failed to start SMS verification', options);
this.name = 'SmsVerificationStartError';
this.sentryContext = sentryContext;
}
}
export class TwilioVerificationRateLimitError extends RateLimitError {
readonly twilioCode?: number;
readonly twilioStatus?: number;
readonly cooldownScope: TwilioCooldownScope;
readonly cooldownMs: number;
constructor(args: {
message: string;
retryAfterSeconds: number;
twilioCode?: number;
twilioStatus?: number;
cooldownScope: TwilioCooldownScope;
cooldownMs?: number;
scope?: 'shared' | 'user';
}) {
const retryAfterSeconds = Math.max(1, Math.ceil(args.retryAfterSeconds));
super({
code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED,
message: args.message,
retryAfter: retryAfterSeconds,
retryAfterDecimal: retryAfterSeconds,
limit: 1,
resetTime: new Date(Date.now() + retryAfterSeconds * 1000),
resetAfterDecimal: retryAfterSeconds,
scope: args.scope ?? 'shared',
});
this.name = 'TwilioVerificationRateLimitError';
this.twilioCode = args.twilioCode;
this.twilioStatus = args.twilioStatus;
this.cooldownScope = args.cooldownScope;
this.cooldownMs = args.cooldownMs ?? retryAfterSeconds * 1000;
}
}
export class TwilioSmsProvider implements ISmsProvider {
private readonly verifyApiUrl: string;
private readonly lookupApiUrl: string;
private readonly lookupTimeoutMs: number;
private readonly logger: LoggerInterface;
private readonly config: TwilioSmsProviderConfig;
private readonly fetchFn: typeof fetch;
constructor({config, logger, fetchFn = fetch}: TwilioSmsProviderDependencies) {
this.verifyApiUrl = config.verifyApiUrl ?? SMS_TWILIO_DEFAULT_VERIFY_API_URL;
this.lookupApiUrl = config.lookupApiUrl ?? SMS_TWILIO_DEFAULT_LOOKUP_API_URL;
this.lookupTimeoutMs = config.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
this.logger = logger ?? createLogger('@pkgs/sms/src');
this.config = config;
this.fetchFn = fetchFn;
}
async startVerification(phone: string): Promise<void> {
await this.startVerificationWithResult(phone);
}
async startVerificationWithResult(
phone: string,
options: SmsVerificationStartOptions = {},
): Promise<SmsVerificationStartResult> {
const requestedChannel = options.channel ?? 'sms';
const requestBody: Record<string, string> = {
To: phone,
Channel: requestedChannel,
};
if (options.deviceIp) {
requestBody.DeviceIp = options.deviceIp;
}
if (options.rateLimits) {
for (const [key, value] of Object.entries(options.rateLimits)) {
if (!key || !value) continue;
requestBody[`RateLimits[${key}]`] = value;
}
}
let response: TwilioResponse;
try {
response = await this.requestTwilio('Verifications', requestBody);
} catch (error) {
const sentryContext = this.createStartVerificationSentryContext(phone, {
requestError: error,
});
this.logger.error(sentryContext, '[TwilioSmsProvider] Twilio request failed while starting SMS verification');
throw new SmsVerificationUnavailableError();
}
if (response.ok) {
const parsed = response.body;
if (
!isRecord(parsed) ||
!isNullishString(parsed.channel) ||
(parsed.status !== undefined && parsed.status !== 'pending' && parsed.status !== 'approved')
) {
this.logger.error(
{phone: maskPhoneNumber(phone), status: response.status},
'[TwilioSmsProvider] Invalid verification start response',
);
throw new SmsVerificationUnavailableError();
}
return {
channel: parsed.channel ?? requestedChannel,
};
}
const body = parseTwilioError(response.body);
if (body?.code === TWILIO_INVALID_PHONE_ERROR_CODE) {
throw new InvalidPhoneNumberError();
}
const rateLimitError = this.createRateLimitError(response, body, 'Verifications');
if (rateLimitError) {
throw rateLimitError;
}
const sentryContext = this.createStartVerificationSentryContext(phone, {
response,
body,
});
this.logger.error(sentryContext, '[TwilioSmsProvider] Failed to start SMS verification');
throw new SmsVerificationUnavailableError();
}
async checkVerification(phone: string, code: string): Promise<boolean> {
let response: TwilioResponse;
try {
response = await this.requestTwilio('VerificationCheck', {
To: phone,
Code: code,
});
} catch (error) {
this.logger.error(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Twilio request failed while checking SMS verification',
);
throw new SmsVerificationUnavailableError();
}
if (!response.ok) {
const body = parseTwilioError(response.body);
const rateLimitError = this.createRateLimitError(response, body, 'VerificationCheck');
if (rateLimitError) {
throw rateLimitError;
}
if (response.status >= 500) {
this.logger.error(
{
status: response.status,
code: body?.code,
message: body?.message,
phone: maskPhoneNumber(phone),
},
'[TwilioSmsProvider] Verification check failed with provider error',
);
throw new SmsVerificationUnavailableError();
}
return false;
}
const body = response.body;
if (!isRecord(body) || typeof body.status !== 'string') {
this.logger.error(
{phone: maskPhoneNumber(phone), status: response.status},
'[TwilioSmsProvider] Invalid verification check response',
);
throw new SmsVerificationUnavailableError();
}
return body.status === 'approved';
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
const url = `${this.lookupApiUrl}/PhoneNumbers/${encodeURIComponent(phone)}?Fields=line_type_intelligence,sms_pumping_risk`;
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
let response: Response;
try {
response = await this.fetchFn(url, {
method: 'GET',
headers: {Authorization: `Basic ${auth}`},
signal: AbortSignal.timeout(this.lookupTimeoutMs),
});
} catch (error) {
this.logger.warn(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Lookup request failed (fail-open)',
);
return null;
}
if (response.status === 404) {
await response.body?.cancel().catch(() => {
this.logger.warn(
{status: response.status},
'[TwilioSmsProvider] Failed to cancel discarded lookup response body',
);
});
return {
valid: false,
lineType: null,
countryCode: null,
carrierName: null,
smsPumpingRiskScore: null,
};
}
if (!response.ok) {
const body = await this.parseErrorBody(response);
this.logger.warn(
{
status: response.status,
code: body?.code,
message: body?.message,
phone: maskPhoneNumber(phone),
},
'[TwilioSmsProvider] Lookup returned non-OK (fail-open)',
);
return null;
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (error) {
this.logger.warn(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Lookup response JSON parse failed (fail-open)',
);
return null;
}
if (!isTwilioLookupResponse(parsed)) {
this.logger.warn({phone: maskPhoneNumber(phone)}, '[TwilioSmsProvider] Invalid lookup response (fail-open)');
return null;
}
const countryCode = parsed.country_code ?? null;
const carrierName = parsed.line_type_intelligence?.carrier_name ?? null;
const ltiErrorCode = parsed.line_type_intelligence?.error_code ?? null;
const sprErrorCode = parsed.sms_pumping_risk?.error_code ?? null;
if (ltiErrorCode != null) {
this.logger.warn(
{phone: maskPhoneNumber(phone), countryCode, ltiErrorCode},
'[TwilioSmsProvider] Lookup line_type_intelligence reported error_code',
);
}
if (sprErrorCode != null) {
this.logger.warn(
{phone: maskPhoneNumber(phone), countryCode, sprErrorCode},
'[TwilioSmsProvider] Lookup sms_pumping_risk reported error_code',
);
}
return {
valid: parsed.valid,
lineType: normalizeLineType(parsed.line_type_intelligence?.type),
countryCode,
carrierName,
smsPumpingRiskScore: parsed.sms_pumping_risk?.sms_pumping_risk_score ?? null,
};
}
private async requestTwilio(
endpoint: 'Verifications' | 'VerificationCheck',
body: Record<string, string>,
): Promise<TwilioResponse> {
const url = `${this.verifyApiUrl}/Services/${this.config.verifyServiceSid}/${endpoint}`;
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
const response = await this.fetchFn(url, {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
});
const parsed: unknown = await response.json().catch((error: unknown) => {
if (response.ok) throw error;
return null;
});
return {ok: response.ok, status: response.status, body: parsed};
}
private async parseErrorBody(response: Response): Promise<TwilioErrorResponse | null> {
try {
return parseTwilioError(await response.json());
} catch {
return null;
}
}
private createStartVerificationSentryContext(
phone: string,
params: {
response?: Pick<Response, 'status'>;
body?: TwilioErrorResponse | null;
requestError?: unknown;
},
): StartVerificationSentryContext {
const sentryContext: StartVerificationSentryContext = {
smsProvider: 'twilio',
smsOperation: 'start_verification',
twilioEndpoint: 'Verifications',
phone: maskPhoneNumber(phone),
};
if (params.response) {
sentryContext.twilioStatus = params.response.status;
}
if (params.body?.code !== undefined) {
sentryContext.twilioCode = params.body.code;
}
if (params.body?.message !== undefined) {
sentryContext.twilioMessage = params.body.message;
}
if (params.requestError !== undefined) {
sentryContext.twilioRequestError =
params.requestError instanceof Error ? params.requestError.message : String(params.requestError);
}
return sentryContext;
}
private createRateLimitError(
response: Pick<Response, 'status'>,
body: TwilioErrorResponse | null,
endpoint: 'Verifications' | 'VerificationCheck',
): TwilioVerificationRateLimitError | null {
const twilioCode = body?.code;
const twilioStatus = response.status;
if (twilioCode === TWILIO_MAX_SEND_ATTEMPTS_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Too many verification texts were sent recently. Try again later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_CONCURRENT_REQUESTS_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Too many verification requests are already in flight for this number. Try again later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_MAX_CHECK_ATTEMPTS_ERROR_CODE && endpoint === 'VerificationCheck') {
return new TwilioVerificationRateLimitError({
message: 'Too many verification code checks were attempted. Request a new code later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_FRAUD_GUARD_BLOCK_ERROR_CODE || twilioCode === TWILIO_FRAUD_PREVENTION_BLOCK_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Phone verification is temporarily blocked for this destination. Try again later.',
retryAfterSeconds: TWILIO_FRAUD_BLOCK_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_TOO_MANY_REQUESTS_ERROR_CODE || (twilioCode == null && twilioStatus === 429)) {
return new TwilioVerificationRateLimitError({
message: 'Phone verification is temporarily busy. Try again shortly.',
retryAfterSeconds: TWILIO_DEFAULT_BUSY_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'account',
scope: 'user',
});
}
return null;
}
}
@@ -1,24 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export class UnavailableSmsProvider implements ISmsProvider {
async startVerification(_phone: string): Promise<void> {
return;
}
async startVerificationWithResult(_phone: string): Promise<SmsVerificationStartResult> {
return SMS_VERIFICATION_START_SMS_RESULT;
}
async checkVerification(_phone: string, _code: string): Promise<boolean> {
throw new SmsVerificationUnavailableError();
}
async lookupPhone(_phone: string): Promise<PhoneLookupResult | null> {
return null;
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
-18
View File
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineConfig} from 'vitest/config';
export default defineConfig({
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
-8
View File
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {InboundSmsChallengeService} from '@app/api/auth/services/InboundSmsChallengeService';
import type {PhoneAttemptRiskService} from '@app/api/auth/services/PhoneAttemptRiskService';
import type {IPhoneLookupRepository} from '@app/api/auth/services/PhoneLookupRepository';
import type {Config} from '@app/api/Config';
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -17,7 +14,6 @@ import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {ISmsService} from '@pkgs/sms/src/ISmsService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
export interface ApiServices {
@@ -28,14 +24,10 @@ export interface ApiServices {
media: IMediaService;
email: IEmailService;
emailDnsValidation: IEmailDnsValidationService;
sms: ISmsService;
worker: IWorkerService<WorkerTaskName>;
snowflake: ISnowflakeService;
rateLimit: IRateLimitService;
contactChangeLog: UserContactChangeLogService;
inboundSmsChallenge: InboundSmsChallengeService | null;
phoneLookup: IPhoneLookupRepository | null;
phoneAttemptRisk: PhoneAttemptRiskService;
botMfaMirror: BotMfaMirrorService;
userActivityBuffer: UserActivityBuffer;
config: typeof Config;
+5 -6
View File
@@ -5,7 +5,7 @@ import {registerControllers} from '@app/api/app/ControllerRegistry';
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {ILogger} from '@app/api/ILogger';
import {recordHttpClientError} from '@app/api/middleware/AbusiveIpAutoBanner';
import {recordRequestStatus} from '@app/api/middleware/RequestErrorTelemetry';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
@@ -27,11 +27,11 @@ interface APIAppResult {
shutdown: () => Promise<void>;
}
function AbuseAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response | Promise<Response> {
function TelemetryAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response | Promise<Response> {
if (!(err instanceof IpBannedError)) {
const status = resolveErrorStatus(err);
if (status !== null && !ctx.get('user')) {
recordHttpClientError(ctx.req.raw, status);
recordRequestStatus(ctx.req.raw, status);
}
}
return AppErrorHandler(err, ctx);
@@ -49,9 +49,8 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
torExitBlockingEnabled: config.torExitList.enabled,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.onError(TelemetryAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
registerControllers(routes, config);
const app = new Hono<HonoEnv>({strict: true});
@@ -67,7 +66,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
);
app.route('/v1', routes);
app.route('/', routes);
app.onError(AbuseAwareAppErrorHandler);
app.onError(TelemetryAwareAppErrorHandler);
app.notFound(AppNotFoundHandler);
return {
app,
-1
View File
@@ -39,7 +39,6 @@ type IpAuthorizationTicket = Brand<string, 'IpAuthorizationTicket'>;
type MfaTicket = Brand<string, 'MfaTicket'>;
export type WebhookToken = Brand<string, 'WebhookToken'>;
export type MfaBackupCode = Brand<string, 'MfaBackupCode'>;
export type PhoneVerificationToken = Brand<string, 'PhoneVerificationToken'>;
export function createUserID<T extends bigint>(id: T extends BrandedValue ? never : T): UserID {
return brand<T, 'UserID'>(id);
+7 -16
View File
@@ -170,13 +170,12 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
function withOptionalOutboundLookups(
master: MasterConfig,
selfHosted: boolean,
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
overrides: {breachedPasswordCheck?: boolean} = {},
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
tor_exit_list: {enabled: overrides.torExitList},
breached_password_check: {enabled: overrides.breachedPasswordCheck},
},
instance: {
@@ -192,31 +191,23 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
master = await loadConfig();
});
it('keeps both lookups on when the instance is not self-hosted', () => {
it('keeps the lookup on when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('leaves both lookups off on a self-hosted instance', () => {
it('leaves the lookup off on a self-hosted instance', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
it('lets a self-hosted operator switch each lookup on', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
);
expect(config.torExitList.enabled).toBe(true);
it('lets a self-hosted operator switch the lookup on', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true, {breachedPasswordCheck: true}));
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
);
expect(config.torExitList.enabled).toBe(false);
it('lets an operator switch the lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false, {breachedPasswordCheck: false}));
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
+2 -49
View File
@@ -320,45 +320,15 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
}
: undefined,
},
sms: {
enabled: master.integrations.sms.enabled,
accountSid: master.integrations.sms.account_sid,
authToken: master.integrations.sms.auth_token,
verifyServiceSid: master.integrations.sms.verify_service_sid,
inboundChallengeNumber: master.integrations.sms.inbound_challenge_number || undefined,
inboundWebhookAuthToken: master.integrations.sms.inbound_webhook_auth_token || master.integrations.sms.auth_token,
inboundWebhookPublicUrl: master.integrations.sms.inbound_webhook_public_url || undefined,
},
risk: {
enabled: master.integrations.risk_integration.enabled,
ipinfoApiKey: master.integrations.risk_integration.ipinfo_api_key || undefined,
accountPolicyDsl: master.integrations.risk_integration.account_policy_dsl,
ipinfo: {
apiKey: master.integrations.ipinfo.api_key || undefined,
},
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
torExitList: {
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
},
breachedPasswordCheck: {
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
hcaptcha: master.integrations.captcha.hcaptcha
? {
siteKey: master.integrations.captcha.hcaptcha.site_key,
secretKey: master.integrations.captcha.hcaptcha.secret_key,
}
: undefined,
turnstile: master.integrations.captcha.turnstile
? {
siteKey: master.integrations.captcha.turnstile.site_key,
secretKey: master.integrations.captcha.turnstile.secret_key,
}
: undefined,
},
contentModeration: {
nsfwThreshold: master.services.api.content_moderation?.nsfw_threshold ?? 0.7,
},
@@ -484,23 +454,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
configured: master.instance.setup.configured,
},
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
directContactSpam: {
enabled: master.instance.abuse_policy.direct_contact_spam.enabled,
countryCodes: master.instance.abuse_policy.direct_contact_spam.country_codes,
distinctTargetThreshold: master.instance.abuse_policy.direct_contact_spam.distinct_target_threshold,
targetWindowMs: master.instance.abuse_policy.direct_contact_spam.target_window_ms,
action: master.instance.abuse_policy.direct_contact_spam.action,
},
},
domain: {
baseDomain: master.domain.base_domain,
},
-8
View File
@@ -1,9 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext, ApiServices, RequestScope} from '@app/api/ApiContext';
import {CassandraPhoneLookupRepository} from '@app/api/auth/services/PhoneLookupRepository';
import {Config} from '@app/api/Config';
import {getInboundSmsChallengeServiceInstance} from '@app/api/middleware/ServiceMiddleware';
import {
getGatewayService,
getKVClient,
@@ -17,9 +15,7 @@ import {
getContactChangeLogService,
getEmailDnsValidationService,
getEmailService,
getPhoneAttemptRiskService,
getRateLimitService,
getSmsService,
getUserActivityBuffer,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
@@ -39,14 +35,10 @@ function buildApiServices(): ApiServices {
media: getMediaService(),
email: getEmailService(),
emailDnsValidation: getEmailDnsValidationService(),
sms: getSmsService(),
worker: getWorkerService(),
snowflake: getSnowflakeService(),
rateLimit: getRateLimitService(),
contactChangeLog: getContactChangeLogService(),
inboundSmsChallenge: getInboundSmsChallengeServiceInstance(),
phoneLookup: new CassandraPhoneLookupRepository(),
phoneAttemptRisk: getPhoneAttemptRiskService(),
botMfaMirror: getBotMfaMirrorService(),
userActivityBuffer: getUserActivityBuffer(),
config: Config,
-204
View File
@@ -13,7 +13,6 @@ import {
BANNED_EMAIL_COLUMNS,
BANNED_FILE_SHA_COLUMNS,
BANNED_IP_COLUMNS,
BANNED_PHONE_PREFIX_COLUMNS,
BANNED_PHRASE_COLUMNS,
BANNED_PROFILE_SUBSTRING_COLUMNS,
BANNED_URL_COLUMNS,
@@ -22,15 +21,10 @@ import {
type BannedEmailRow,
type BannedFileShaRow,
type BannedIpRow,
type BannedPhonePrefixRow,
type BannedPhraseRow,
type BannedProfileSubstringRow,
type BannedUrlDomainRow,
type BannedUrlRow,
DISPOSABLE_EMAIL_DOMAIN_COLUMNS,
type DisposableEmailDomainRow,
SUSPICIOUS_EMAIL_DOMAIN_COLUMNS,
type SuspiciousEmailDomainRow,
} from '@app/api/database/types/AdminArchiveTypes';
import {
ADMIN_API_KEY_BY_CREATOR_COLUMNS,
@@ -69,11 +63,7 @@ import {
PASSWORD_RESET_TOKEN_COLUMNS,
type PasswordChangeTicketRow,
type PasswordResetTokenRow,
PHONE_TOKEN_COLUMNS,
type PhoneTokenRow,
USER_COUNTRY_HISTORY_COLUMNS,
USER_SSO_IDENTITY_COLUMNS,
type UserCountryHistoryRow,
type UserSsoIdentityRow,
WEBAUTHN_CREDENTIAL_COLUMNS,
type WebAuthnCredentialRow,
@@ -260,40 +250,6 @@ import {
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type MessageReportSubmissionByReporterRow,
} from '@app/api/database/types/ReportTypes';
import {
INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
INBOUND_SMS_CHALLENGE_COLUMNS,
type InboundSmsChallengeByUserRow,
type InboundSmsChallengeRow,
LATEST_RISK_CONTEXT_BY_USER_COLUMNS,
type LatestRiskContextByUserRow,
PHONE_LOOKUP_CACHE_COLUMNS,
PHONE_VERIFICATION_ATTEMPT_COLUMNS,
type PhoneLookupCacheRow,
type PhoneVerificationAttemptRow,
REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
REGISTRATION_EVENT_BY_IP_COLUMNS,
REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
type RegistrationEventByEmailDomainRow,
type RegistrationEventByIpRow,
type RegistrationEventByPlusAddressBaseRow,
type RegistrationEventBySubnetRow,
RISK_ASSESSMENT_BY_USER_COLUMNS,
RISK_ASSESSMENT_COLUMNS,
RISK_OUTCOME_BY_ASN_COLUMNS,
RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
RISK_OUTCOME_BY_IP_COLUMNS,
RISK_OUTCOME_BY_SUBNET_COLUMNS,
type RiskAssessmentByUserRow,
type RiskAssessmentRow,
type RiskOutcomeByAsnRow,
type RiskOutcomeByEmailDomainRow,
type RiskOutcomeByIpRow,
type RiskOutcomeBySubnetRow,
SUSPICIOUS_IP_COLUMNS,
type SuspiciousIpRow,
} from '@app/api/database/types/RiskTypes';
import {
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
@@ -788,21 +744,6 @@ export const BannedEmails = defineTable<BannedEmailRow, 'email_lower'>({
columns: BANNED_EMAIL_COLUMNS,
primaryKey: ['email_lower'],
});
export const BannedPhonePrefixes = defineTable<BannedPhonePrefixRow, 'prefix'>({
name: 'banned_phone_prefixes',
columns: BANNED_PHONE_PREFIX_COLUMNS,
primaryKey: ['prefix'],
});
export const SuspiciousEmailDomains = defineTable<SuspiciousEmailDomainRow, 'domain'>({
name: 'suspicious_email_domains',
columns: SUSPICIOUS_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['domain'],
});
export const DisposableEmailDomains = defineTable<DisposableEmailDomainRow, 'domain'>({
name: 'disposable_email_domains',
columns: DISPOSABLE_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['domain'],
});
export const BannedPhrases = defineTable<BannedPhraseRow, 'phrase'>({
name: 'banned_phrases',
columns: BANNED_PHRASE_COLUMNS,
@@ -888,12 +829,6 @@ export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'us
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('48 hours'),
});
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
name: 'phone_tokens',
columns: PHONE_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
name: 'auth_sessions',
columns: AUTH_SESSION_COLUMNS,
@@ -916,12 +851,6 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
primaryKey: ['user_id', 'session_id_hash'],
defaultTtlSeconds: seconds('30 days'),
});
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
name: 'user_country_history',
columns: USER_COUNTRY_HISTORY_COLUMNS,
primaryKey: ['user_id', 'country'],
defaultTtlSeconds: seconds('365 days'),
});
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
name: 'mfa_backup_codes',
columns: MFA_BACKUP_CODE_COLUMNS,
@@ -1174,139 +1103,6 @@ export const NcmecUserWorkflows = defineTable<NcmecUserWorkflowRow, 'user_id'>({
columns: NCMEC_USER_WORKFLOW_COLUMNS,
primaryKey: ['user_id'],
});
export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip' | 'created_at' | 'user_id', 'ip'>({
name: 'registration_events_by_ip',
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsBySubnet = defineTable<
RegistrationEventBySubnetRow,
'subnet' | 'created_at' | 'user_id',
'subnet'
>({
name: 'registration_events_by_subnet',
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByEmailDomain = defineTable<
RegistrationEventByEmailDomainRow,
'email_domain' | 'created_at' | 'user_id',
'email_domain'
>({
name: 'registration_events_by_email_domain',
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByPlusAddressBase = defineTable<
RegistrationEventByPlusAddressBaseRow,
'plus_address_base' | 'created_at' | 'user_id',
'plus_address_base'
>({
name: 'registration_events_by_plus_address_base',
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
partitionKey: ['plus_address_base'],
defaultTtlSeconds: seconds('30 days'),
});
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
name: 'latest_risk_context_by_user',
columns: LATEST_RISK_CONTEXT_BY_USER_COLUMNS,
primaryKey: ['user_id'],
});
export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
name: 'suspicious_ips',
columns: SUSPICIOUS_IP_COLUMNS,
primaryKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
{
name: 'risk_outcomes_by_ip',
columns: RISK_OUTCOME_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
},
);
export const RiskOutcomesBySubnet = defineTable<
RiskOutcomeBySubnetRow,
'subnet' | 'created_at' | 'user_id' | 'outcome_code',
'subnet'
>({
name: 'risk_outcomes_by_subnet',
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByEmailDomain = defineTable<
RiskOutcomeByEmailDomainRow,
'email_domain' | 'created_at' | 'user_id' | 'outcome_code',
'email_domain'
>({
name: 'risk_outcomes_by_email_domain',
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByAsn = defineTable<
RiskOutcomeByAsnRow,
'asn' | 'created_at' | 'user_id' | 'outcome_code',
'asn'
>({
name: 'risk_outcomes_by_asn',
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['asn'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
name: 'risk_assessments',
columns: RISK_ASSESSMENT_COLUMNS,
primaryKey: ['assessment_id'],
});
export const RiskAssessmentsByUser = defineTable<RiskAssessmentByUserRow, 'user_id' | 'created_at', 'user_id'>({
name: 'risk_assessments_by_user',
columns: RISK_ASSESSMENT_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
});
export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challenge_code'>({
name: 'inbound_sms_challenges',
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
primaryKey: ['challenge_code'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const InboundSmsChallengesByUser = defineTable<
InboundSmsChallengeByUserRow,
'user_id' | 'created_at',
'user_id'
>({
name: 'inbound_sms_challenges_by_user',
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
name: 'phone_lookup_cache',
columns: PHONE_LOOKUP_CACHE_COLUMNS,
primaryKey: ['phone'],
defaultTtlSeconds: seconds('7 days'),
});
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
name: 'phone_verification_attempts',
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
primaryKey: ['attempt_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
name: 'billing_customers',
columns: BILLING_CUSTOMER_COLUMNS,
+1 -76
View File
@@ -2,7 +2,7 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
@@ -20,21 +20,16 @@ import type {
BannedUrlDomainRow,
BannedUrlRow,
} from '@app/api/database/types/AdminArchiveTypes';
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import {
AdminAuditLogs,
BannedAvatarHashes,
BannedEmails,
BannedFileShas,
BannedIps,
BannedPhonePrefixes,
BannedPhrases,
BannedProfileSubstrings,
BannedUrlDomains,
BannedUrls,
DisposableEmailDomains,
SuspiciousEmailDomains,
} from '@app/api/Tables';
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
@@ -51,20 +46,10 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
where: SuspiciousEmailDomains.where.eq('domain'),
});
const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select();
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
where: DisposableEmailDomains.where.eq('domain'),
});
const createLoadDisposableEmailDomainsQuery = (limit?: number) =>
limit ? DisposableEmailDomains.select({limit}) : DisposableEmailDomains.select();
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
where: BannedPhrases.where.eq('phrase'),
});
const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select();
const LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY = BannedPhonePrefixes.select();
const IS_URL_BANNED_QUERY = BannedUrls.select({
where: BannedUrls.where.eq('url_canonical'),
});
@@ -263,59 +248,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.email_lower);
}
async isEmailDomainSuspicious(domain: string): Promise<boolean> {
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY.bind({domain: domainLower}));
return !!result;
}
async addSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(SuspiciousEmailDomains.insert({domain: domainLower}));
}
async removeSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
}
async loadAllSuspiciousEmailDomains(): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({}));
return rows.map((row) => row.domain);
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
if (!Config.blocklistFeeds.enabled) return false;
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_DISPOSABLE_QUERY.bind({domain: domainLower}));
return !!result;
}
async addDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(DisposableEmailDomains.insert({domain: domainLower}));
}
async removeDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(DisposableEmailDomains.deleteByPk({domain: domainLower}));
}
async listDisposableEmailDomains(limit?: number): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(createLoadDisposableEmailDomainsQuery(limit).bind({}));
return rows.map((row) => row.domain);
}
async isPhraseBanned(phrase: string): Promise<boolean> {
const phraseLower = canonicalizeStoredPhrase(phrase);
const result = await fetchOne<{
@@ -341,13 +273,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.phrase);
}
async loadAllBannedPhonePrefixes(): Promise<Array<string>> {
const rows = await fetchMany<{
prefix: string;
}>(LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY.bind({}));
return rows.map((row) => row.prefix);
}
async isUrlBanned(url: string): Promise<boolean> {
const canonical = url.toLowerCase();
const result = await fetchOne<{
-6
View File
@@ -37,8 +37,6 @@ import {
} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
@@ -81,10 +79,8 @@ export class AdminService {
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly suspiciousIpRepository: ISuspiciousIpRepository,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
this.auditService = new AdminAuditService(this.adminRepository, snowflake, {
@@ -97,7 +93,6 @@ export class AdminService {
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
suspiciousIpRepository: this.suspiciousIpRepository,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -111,7 +106,6 @@ export class AdminService {
kvDeletionQueue: getKVAccountDeletionQueue(),
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
stripe: this.stripe,
riskHistoryRepository: this.riskHistoryRepository,
reportService: this.reportService,
});
this.guildServiceAggregate = new AdminGuildService({
@@ -59,22 +59,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract isEmailDomainSuspicious(domain: string): Promise<boolean>;
abstract addSuspiciousEmailDomain(domain: string): Promise<void>;
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
abstract loadAllSuspiciousEmailDomains(): Promise<Array<string>>;
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
abstract addDisposableEmailDomain(domain: string): Promise<void>;
abstract removeDisposableEmailDomain(domain: string): Promise<void>;
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
abstract isPhraseBanned(phrase: string): Promise<boolean>;
abstract banPhrase(phrase: string): Promise<void>;
@@ -83,8 +67,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedPhrases(): Promise<Array<string>>;
abstract loadAllBannedPhonePrefixes(): Promise<Array<string>>;
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract isUrlBanned(url: string): Promise<boolean>;
@@ -47,7 +47,6 @@ import {
BulkBanFileShasRequest,
BulkJobResponse,
CheckAvatarHashRequest,
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodType} from 'zod';
@@ -76,17 +75,6 @@ const BLOCKLIST_CATALOG = [
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'email-domain-suspicious' as const,
description:
'Email domains flagged as suspicious. Registration is not blocked, but new accounts using the domain must verify a phone number before they can act on the platform. The list itself is not exposed to users.',
value_field: 'domain',
fields: [],
scoped: false,
supports_bulk_create: false,
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'phrase' as const,
description:
@@ -154,11 +142,6 @@ const BLOCKLIST_CATALOG = [
const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: string; remove: string}> = {
ip: {add: AdminACLs.BAN_IP_ADD, check: AdminACLs.BAN_IP_CHECK, remove: AdminACLs.BAN_IP_REMOVE},
email: {add: AdminACLs.BAN_EMAIL_ADD, check: AdminACLs.BAN_EMAIL_CHECK, remove: AdminACLs.BAN_EMAIL_REMOVE},
'email-domain-suspicious': {
add: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_ADD,
check: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_CHECK,
remove: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
},
phrase: {add: AdminACLs.BAN_PHRASE_ADD, check: AdminACLs.BAN_PHRASE_CHECK, remove: AdminACLs.BAN_PHRASE_REMOVE},
url: {add: AdminACLs.BAN_URL_ADD, check: AdminACLs.BAN_URL_CHECK, remove: AdminACLs.BAN_URL_REMOVE},
'url-domain': {
@@ -186,7 +169,6 @@ const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: s
const BLOCKLIST_AUDIT_TARGET_TYPES: Record<AdminBlocklistListType, string> = {
ip: 'ip',
email: 'email',
'email-domain-suspicious': 'email_domain',
phrase: 'phrase',
url: 'url',
'url-domain': 'url_domain',
@@ -255,8 +237,6 @@ async function checkBlocklistEntry(
return bans.checkIpBan({ip: entryValue});
case 'email':
return bans.checkEmailBan({email: entryValue});
case 'email-domain-suspicious':
return bans.checkSuspiciousEmailDomain({domain: entryValue});
case 'phrase':
return bans.checkPhraseBan({phrase: entryValue});
case 'url':
@@ -375,13 +355,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.banEmail(await parseBlocklistBody(BanEmailRequest, raw), adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.addSuspiciousEmailDomain(
await parseBlocklistBody(SuspiciousEmailDomainRequest, raw),
adminUserId,
auditLogReason,
);
break;
case 'phrase':
await bans.banPhrase(await parseBlocklistBody(BanPhraseRequest, raw), adminUserId, auditLogReason);
break;
@@ -624,9 +597,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.unbanEmail({email: entryValue}, adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.removeSuspiciousEmailDomain({domain: entryValue}, adminUserId, auditLogReason);
break;
case 'phrase':
await bans.unbanPhrase({phrase: entryValue}, adminUserId, auditLogReason);
break;
@@ -35,12 +35,9 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
@@ -67,11 +64,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
const [
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
captcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -79,11 +74,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -115,11 +108,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
captcha,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -139,11 +130,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
youtube_enabled: policy.youtube_enabled,
bluesky_enabled: policy.bluesky_enabled,
},
deferred_phone_gate: {
enabled: policy.deferred_phone_gate_enabled,
window_hours: policy.deferred_phone_gate_window_hours,
member_threshold: policy.deferred_phone_gate_member_threshold,
},
services_resolved: resolvedServices,
services_available: {
gif: integrations.gif.effective_available,
@@ -377,18 +363,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
@@ -413,28 +387,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
if (data.experiment_delivery) {
@@ -535,15 +491,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
api_key: readOptionalField(data.integrations.youtube, 'api_key'),
})
: undefined,
captcha: data.integrations.captcha
? omitUndefinedFields({
provider: readOptionalField(data.integrations.captcha, 'provider'),
hcaptcha_site_key: readOptionalField(data.integrations.captcha, 'hcaptcha_site_key'),
hcaptcha_secret_key: readOptionalField(data.integrations.captcha, 'hcaptcha_secret_key'),
turnstile_site_key: readOptionalField(data.integrations.captcha, 'turnstile_site_key'),
turnstile_secret_key: readOptionalField(data.integrations.captcha, 'turnstile_secret_key'),
})
: undefined,
email: data.integrations.email
? {
...omitUndefinedFields({
@@ -895,17 +842,6 @@ function planInstancePolicyPatch(
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
}
}
if (policy.deferred_phone_gate) {
if (policy.deferred_phone_gate.enabled !== undefined) {
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
}
if (policy.deferred_phone_gate.window_hours !== undefined) {
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
}
if (policy.deferred_phone_gate.member_threshold !== undefined) {
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
return {patch, enablesSingleCommunity};
}
@@ -17,10 +17,12 @@ import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas
import {
AdminAclListResponse,
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
AdminUserDeletionScheduleRequest,
AdminUserDmChannelListQuery,
AdminUserDmChannelListResponse,
@@ -872,6 +874,30 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.post(
'/admin/users/:user_id/ban/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_TEMP_BAN),
Validator('param', UserIdParam),
Validator('json', AdminUserBanNoteRequest),
OpenAPI({
operationId: 'annotate_admin_user_ban',
summary: 'Add a note to a user ban',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
tags: 'Admin',
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.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const {user_id: userId} = ctx.req.valid('param');
await adminService.userService.banService.annotateBan({user_id: userId, ...ctx.req.valid('json')}, adminUserId);
return ctx.body(null, 204);
},
);
app.put(
'/admin/users/:user_id/deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -886,7 +912,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
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.',
'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.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -909,6 +935,7 @@ export function UserAdminController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE),
Validator('param', UserIdParam),
Validator('json', AdminUserDeletionCancelRequest),
OpenAPI({
operationId: 'cancel_admin_user_deletion',
summary: 'Cancel user deletion',
@@ -917,7 +944,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.',
'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.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -927,7 +954,7 @@ export function UserAdminController(app: HonoApp) {
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.deletionService.cancelAccountDeletion(
{user_id: userId},
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
@@ -20,6 +20,7 @@ export async function mapUserToAdminResponse(
const canViewEmail = !acls || hasAcl(acls, AdminACLs.USER_VIEW_EMAIL);
const canViewDob = !acls || hasAcl(acls, AdminACLs.USER_VIEW_DOB);
const canViewIp = !acls || hasAcl(acls, AdminACLs.USER_VIEW_IP);
const canViewAuditLog = !acls || hasAcl(acls, AdminACLs.AUDIT_LOG_VIEW);
const lastActiveIpReverse =
canViewIp && user.lastActiveIp ? await getIpAddressReverse(user.lastActiveIp, cacheService) : null;
let lastActiveLocation: string | null = null;
@@ -65,6 +66,9 @@ export async function mapUserToAdminResponse(
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
deletion_public_reason: user.deletionPublicReason,
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
acls: user.acls ? Array.from(user.acls) : [],
traits: Array.from(user.traits).sort(),
has_totp: user.totpSecret !== null,
@@ -65,6 +65,10 @@ export class AdminAuditService {
}
}
async findAuditLog(logId: bigint): Promise<AdminAuditLog | null> {
return this.adminRepository.getAuditLog(logId);
}
async getAuditLog(logId: bigint): Promise<AdminAuditLogResponse | null> {
const log = await this.adminRepository.getAuditLog(logId);
if (!log) {
@@ -4,6 +4,8 @@ import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
BANNED_FILE_SHAS_REFRESH_CHANNEL,
@@ -23,14 +25,6 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {
getIpBanBlastRadiusVerdict,
getSuspiciousIpSkipReason,
isSingleIpBanCandidate,
} from '@app/api/risk/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -40,14 +34,13 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
auditService: AdminAuditService;
ipInfoService: IpInfoService;
suspiciousIpRepository: ISuspiciousIpRepository;
}
interface AdminBlocklistEntry {
@@ -207,86 +200,6 @@ export class AdminBanManagementService {
return {banned};
}
async markSuspiciousIpForScheduledDeletion(
data: {
ip: string;
sourceUserId: UserID;
deletionReasonCode: number;
},
adminUserId: UserID,
auditLogReason: string | null,
): Promise<void> {
const parsed = tryParseSingleIp(data.ip);
if (!parsed) {
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'mark_suspicious_ip_skipped_invalid',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
});
return;
}
let info: IpInfoLookupResult;
try {
info = await this.deps.ipInfoService.lookup(parsed.canonical, {
source: 'admin.scheduled_deletion_suspicious_ip',
reason: 'pre_write_suspicious_ip_guard',
metadata: {
source_user_id: data.sourceUserId.toString(),
deletion_reason_code: data.deletionReasonCode,
},
});
} catch (error) {
Logger.warn({error, ip: parsed.canonical}, 'IPInfo guard failed while marking suspicious IP');
return;
}
const skipReason = getSuspiciousIpSkipReason(info);
if (skipReason) {
Logger.info({ip: parsed.canonical, skipReason}, 'Skipping suspicious IP marker from scheduled deletion');
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: `mark_suspicious_ip_skipped_${skipReason}`,
auditLogReason,
metadata: new Map([
['ip', parsed.canonical],
['source_user_id', data.sourceUserId.toString()],
['deletion_reason_code', data.deletionReasonCode.toString()],
['provider_name', info.anonymous.providerName ?? ''],
]),
});
return;
}
await this.deps.suspiciousIpRepository.markSuspiciousIp({
ip: parsed.canonical,
source: 'scheduled_deletion',
reason: 'account_scheduled_for_deletion',
sourceUserId: data.sourceUserId,
deletionReasonCode: data.deletionReasonCode,
providerName: info.anonymous.providerName,
asn: info.asn.number,
asnName: info.asn.name,
asnType: info.asn.type,
riskNote: info.riskNote,
});
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'mark_suspicious_ip',
auditLogReason,
metadata: new Map([
['ip', parsed.canonical],
['source_user_id', data.sourceUserId.toString()],
['deletion_reason_code', data.deletionReasonCode.toString()],
['provider_name', info.anonymous.providerName ?? ''],
]),
});
}
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
if (!isSingleIpBanCandidate(ip)) {
return false;
@@ -350,50 +263,6 @@ export class AdminBanManagementService {
return {banned};
}
async addSuspiciousEmailDomain(
data: {
domain: string;
},
adminUserId: UserID,
auditLogReason: string | null,
) {
const {adminRepository} = this.deps;
await adminRepository.addSuspiciousEmailDomain(data.domain);
await this.createBlocklistAuditLog({
adminUserId,
targetType: 'email_domain',
action: 'add_suspicious_email_domain',
auditLogReason,
metadata: new Map([['domain', data.domain]]),
});
}
async removeSuspiciousEmailDomain(
data: {
domain: string;
},
adminUserId: UserID,
auditLogReason: string | null,
) {
const {adminRepository} = this.deps;
await adminRepository.removeSuspiciousEmailDomain(data.domain);
await this.createBlocklistAuditLog({
adminUserId,
targetType: 'email_domain',
action: 'remove_suspicious_email_domain',
auditLogReason,
metadata: new Map([['domain', data.domain]]),
});
}
async checkSuspiciousEmailDomain(data: {domain: string}): Promise<{
banned: boolean;
}> {
const {adminRepository} = this.deps;
const banned = await adminRepository.isEmailDomainSuspicious(data.domain);
return {banned};
}
async banPhrase(
data: {
phrase: string;
@@ -872,10 +741,6 @@ export class AdminBanManagementService {
const rows = await adminRepository.loadAllBannedEmails();
return rows.map((value) => createBlocklistEntry(listType, value));
}
case 'email-domain-suspicious': {
const rows = await adminRepository.loadAllSuspiciousEmailDomains();
return rows.map((value) => createBlocklistEntry(listType, value));
}
case 'phrase': {
const rows = await adminRepository.loadAllBannedPhrases();
return rows.map((value) => createBlocklistEntry(listType, value));
@@ -6,9 +6,13 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthSession from '@app/api/auth/AuthSession';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {AdminUserBanNoteRequest, TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
interface AdminUserBanServiceDeps {
apiContext: ApiContext;
@@ -70,11 +74,49 @@ export class AdminUserBanService {
['banned_until', tempBannedUntil.toISOString()],
]),
});
await emitAdminAction(adminUserId, userId, 'temp_ban', {durationHours: data.duration_hours});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async annotateBan(data: AdminUserBanNoteRequest & {user_id: bigint}, adminUserId: UserID): Promise<void> {
const {users: userRepository} = this.deps.apiContext.services;
const {auditService} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const banLog = await auditService.findAuditLog(data.ban_audit_log_id);
if (banLog?.action !== 'temp_ban' || banLog.targetType !== 'user' || banLog.targetId !== BigInt(userId)) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_FORM_BODY,
message: 'ban_audit_log_id does not name a ban of this user',
});
}
const bannedUntil = user.tempBannedUntil;
if (
(user.flags & UserFlags.DISABLED) === 0n ||
!bannedUntil ||
bannedUntil.getTime() <= Date.now() ||
banLog.metadata.get('banned_until') !== bannedUntil.toISOString()
) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'ban_audit_log_id does not name the current ban of this user',
});
}
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'annotate_ban',
auditLogReason: data.note,
metadata: new Map([['ban_audit_log_id', data.ban_audit_log_id.toString()]]),
});
}
async unbanUser(
data: {
user_id: bigint;
@@ -110,6 +152,7 @@ export class AdminUserBanService {
auditLogReason,
metadata: new Map(),
});
await emitAdminAction(adminUserId, userId, 'unban');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -8,18 +8,26 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
import * as AuthSession from '@app/api/auth/AuthSession';
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import {ReportStatus} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {NoPendingDeletionError} from '@fluxer/errors/src/domains/core/NoPendingDeletionError';
import {ReportAlreadyResolvedError} from '@fluxer/errors/src/domains/moderation/ReportAlreadyResolvedError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {ScheduleAccountDeletionRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {
AdminUserDeletionCancelRequest,
ScheduleAccountDeletionRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type Stripe from 'stripe';
interface AdminUserDeletionServiceDeps {
@@ -31,11 +39,26 @@ interface AdminUserDeletionServiceDeps {
kvDeletionQueue: KVAccountDeletionQueueService;
stripe: Stripe | null;
billingRepository: BillingRepository;
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
}
const minUserRequestedDeletionDays = 14;
const minStandardDeletionDays = 60;
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
if (!user.pendingDeletionAt) return [];
return [
[`${prefix}_pending_deletion_at`, user.pendingDeletionAt.toISOString()],
[`${prefix}_scheduled_by`, user.deletionScheduledBy?.toString() ?? ''],
[`${prefix}_scheduled_at`, user.deletionScheduledAt?.toISOString() ?? ''],
[`${prefix}_reason_code`, user.deletionReasonCode?.toString() ?? ''],
];
}
function sameInstant(left: Date, right: string): boolean {
return left.getTime() === new Date(right).getTime();
}
export function resolveDeletionDays(reasonCode: number, requestedDays: number): number {
const minDays =
reasonCode === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
@@ -70,8 +93,18 @@ export class AdminUserDeletionService {
if (!user) {
throw new UnknownUserError();
}
if (
user.pendingDeletionAt &&
(!data.replace_pending_deletion_at || !sameInstant(user.pendingDeletionAt, data.replace_pending_deletion_at))
) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'A deletion is already scheduled for this account',
});
}
const daysUntilDeletion = resolveDeletionDays(data.reason_code, data.days_until_deletion);
const pendingDeletionAt = new Date();
const scheduledAt = new Date();
const pendingDeletionAt = new Date(scheduledAt);
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
const updatedUser = await userRepository.updateDeletionSchedule(user, {
flags: user.flags | UserFlags.DELETED,
@@ -79,6 +112,8 @@ export class AdminUserDeletionService {
deletion_reason_code: data.reason_code,
deletion_public_reason: data.public_reason ?? null,
deletion_audit_log_reason: auditLogReason,
deletion_scheduled_by: adminUserId,
deletion_scheduled_at: scheduledAt,
});
await reschedulePendingDeletion({
userId,
@@ -89,6 +124,8 @@ export class AdminUserDeletionService {
deletionQueue: this.deps.kvDeletionQueue,
});
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await this.deps.oauth2Tokens.deleteAllAccessTokensForUser(userId);
await this.deps.oauth2Tokens.deleteAllRefreshTokensForUser(userId);
const {stripe, billingRepository} = this.deps;
if (user.stripeSubscriptionId && stripe) {
try {
@@ -158,17 +195,16 @@ export class AdminUserDeletionService {
metadata: new Map([
['days', daysUntilDeletion.toString()],
['reason_code', data.reason_code.toString()],
['pending_deletion_at', pendingDeletionAt.toISOString()],
...describePendingDeletion(user, 'replaced'),
]),
});
let knownIps: ReadonlySet<string> = new Set();
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
await this.banIdentifiersForScheduledDeletion({
user,
adminUserId,
auditLogReason,
deletionReasonCode: data.reason_code,
});
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
await this.resolvePendingReportsAgainstUser({user, adminUserId});
}
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
try {
@@ -190,9 +226,7 @@ export class AdminUserDeletionService {
}
async cancelAccountDeletion(
data: {
user_id: bigint;
},
data: AdminUserDeletionCancelRequest & {user_id: bigint},
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
@@ -204,6 +238,15 @@ export class AdminUserDeletionService {
if (!user) {
throw new UnknownUserError();
}
if (!user.pendingDeletionAt) {
throw new NoPendingDeletionError();
}
if (!sameInstant(user.pendingDeletionAt, data.expected_pending_deletion_at)) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'The pending deletion does not match expected_pending_deletion_at',
});
}
const updatedUser = await userRepository.updateDeletionSchedule(user, {
flags: user.flags & ~UserFlags.DELETED & ~UserFlags.SELF_DELETED,
pending_deletion_at: null,
@@ -218,8 +261,8 @@ export class AdminUserDeletionService {
deletionQueue: this.deps.kvDeletionQueue,
});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
await emailService.sendUnbanNotification(user.email, user.username, auditLogReason || null, user.locale);
if (data.notify_user && user.email) {
await emailService.sendUnbanNotification(user.email, user.username, null, user.locale);
}
await auditService.createAuditLog({
adminUserId,
@@ -227,8 +270,12 @@ export class AdminUserDeletionService {
targetId: BigInt(userId),
action: 'cancel_deletion',
auditLogReason,
metadata: new Map(),
metadata: new Map([
...describePendingDeletion(user, 'cancelled'),
['notify_user', data.notify_user ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'cancel_deletion');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -238,9 +285,8 @@ export class AdminUserDeletionService {
user: User;
adminUserId: UserID;
auditLogReason: string | null;
deletionReasonCode: number;
}): Promise<void> {
const {user, adminUserId, auditLogReason, deletionReasonCode} = params;
}): Promise<ReadonlySet<string>> {
const {user, adminUserId, auditLogReason} = params;
const {users: userRepository} = this.deps.apiContext.services;
const {banManagementService} = this.deps;
const reason = auditLogReason ?? 'auto-enforcement on scheduled deletion';
@@ -251,52 +297,35 @@ export class AdminUserDeletionService {
Logger.warn({error, userId: user.id.toString()}, 'Failed to auto-ban email on scheduled deletion');
}
}
const ipsToReview = new Set<string>();
const knownIps = new Set<string>();
if (user.lastActiveIp) {
ipsToReview.add(user.lastActiveIp);
knownIps.add(user.lastActiveIp);
}
try {
const authorizedIps = await userRepository.getAuthorizedIps(user.id);
for (const {ip} of authorizedIps) {
if (ip) ipsToReview.add(ip);
if (ip) knownIps.add(ip);
}
} catch (error) {
Logger.warn({error, userId: user.id.toString()}, 'Failed to list authorized IPs for scheduled deletion review');
Logger.warn({error, userId: user.id.toString()}, 'Failed to list authorized IPs for scheduled deletion');
}
try {
const sessions = await userRepository.listAuthSessions(user.id);
for (const session of sessions) {
if (session.clientIp) ipsToReview.add(session.clientIp);
if (session.clientIp) knownIps.add(session.clientIp);
}
} catch (error) {
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth sessions for scheduled deletion review');
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth sessions for scheduled deletion');
}
try {
const tombstones = await userRepository.listAuthSessionTombstones(user.id);
for (const tombstone of tombstones) {
if (tombstone.clientIp) ipsToReview.add(tombstone.clientIp);
if (tombstone.clientIp) knownIps.add(tombstone.clientIp);
}
} catch (error) {
Logger.warn(
{error, userId: user.id.toString()},
'Failed to list auth session tombstones for scheduled deletion review',
);
}
for (const ip of ipsToReview) {
try {
await banManagementService.markSuspiciousIpForScheduledDeletion(
{
ip,
sourceUserId: user.id,
deletionReasonCode,
},
adminUserId,
reason,
);
} catch (error) {
Logger.warn({error, userId: user.id.toString(), ip}, 'Failed to mark suspicious IP on scheduled deletion');
}
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth session tombstones for scheduled deletion');
}
return knownIps;
}
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
@@ -11,10 +11,9 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
@@ -23,7 +22,6 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
@@ -54,7 +52,6 @@ interface AdminUserSecurityServiceDeps {
apiContext: ApiContext;
auditService: AdminAuditService;
updatePropagator: AdminUserUpdatePropagator;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>;
}
interface FlagAuditMetadataParams {
@@ -158,6 +155,7 @@ export class AdminUserSecurityService {
newFlags,
}),
});
await emitAdminAction(adminUserId, userId, 'update_flags');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -433,6 +431,7 @@ export class AdminUserSecurityService {
],
),
});
await emitAdminAction(adminUserId, userId, 'set_phone_verified');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -471,12 +470,6 @@ export class AdminUserSecurityService {
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
) {
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
}
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -485,6 +478,7 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
});
await emitAdminAction(adminUserId, userId, 'set_suspicious_flags');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -514,11 +508,6 @@ export class AdminUserSecurityService {
);
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await this.recordRiskOutcomes(
userId,
data.flags !== 0 ? ['challenged', 'disabled_suspicious'] : ['disabled_suspicious'],
'admin_disable_suspicious_activity',
);
if (user.email) {
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
}
@@ -530,6 +519,7 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
});
await emitAdminAction(adminUserId, userId, 'disable_suspicious');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -720,24 +710,4 @@ export class AdminUserSecurityService {
}),
};
}
private async recordRiskOutcomes(
userId: UserID,
outcomeCodes: ReadonlyArray<HistoricalOutcomeCode>,
source: string,
): Promise<void> {
if (outcomeCodes.length === 0) {
return;
}
try {
await this.deps.riskHistoryRepository.recordOutcomeForUser({
userId: userId.toString(),
occurredAt: new Date(),
source,
outcomeCodes,
});
} catch (error) {
Logger.warn({error, userId, source}, 'Failed to persist admin risk history outcome');
}
}
}
@@ -19,8 +19,8 @@ import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAcco
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -46,7 +46,6 @@ interface AdminUserServiceDeps {
kvDeletionQueue: KVAccountDeletionQueueService;
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
stripe: Stripe | null;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>;
reportService: ReportService;
}
@@ -94,7 +93,6 @@ export class AdminUserService {
apiContext: deps.apiContext,
auditService: deps.auditService,
updatePropagator: this.updatePropagator,
riskHistoryRepository: deps.riskHistoryRepository,
});
this.banService = new AdminUserBanService({
apiContext: deps.apiContext,
@@ -110,6 +108,7 @@ export class AdminUserService {
kvDeletionQueue: deps.kvDeletionQueue,
stripe: deps.stripe,
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
});
this.contactChangeLogService = contactChangeLog;
}
@@ -44,7 +44,6 @@ export class AdminGuildMembershipService {
throw new UnknownUserError();
}
await guildService.members.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
sendJoinMessage,
@@ -5,10 +5,9 @@ import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {createUserID} from '@app/api/BrandedTypes';
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
import {getConfig} from '@app/api/Config';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
@@ -28,7 +27,7 @@ function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookup
return {
ip: CARRIER_IP,
available: true,
riskNote: 'test',
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
@@ -101,7 +100,6 @@ function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookup
adminRepository: adminRepository as unknown as IAdminRepository,
auditService: auditService as unknown as AdminAuditService,
ipInfoService: ipInfoService as unknown as IpInfoService,
suspiciousIpRepository: {} as unknown as ISuspiciousIpRepository,
});
return {service, bannedIps, auditCalls};
}
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {decodeHeaderUtf8} from '@app/api/middleware/AuditLogMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function asReceivedHeader(value: string): string {
return Buffer.from(value, 'utf8').toString('latin1');
}
async function auditLogsFor(targetUserId: string, action: string): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
return logs.filter((log) => log.action === action && log.targetId.toString() === targetUserId);
}
describe('decodeHeaderUtf8', () => {
test('reads UTF-8 bytes that arrived as a latin1 string', () => {
expect(decodeHeaderUtf8(asReceivedHeader('§ 4.2 räksmörgås 日本'))).toBe('§ 4.2 räksmörgås 日本');
});
test('keeps ASCII and latin1 text that is not UTF-8', () => {
expect(decodeHeaderUtf8('Batch 12')).toBe('Batch 12');
expect(decodeHeaderUtf8('café')).toBe('café');
});
});
describe('Admin ban reasons and notes', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeEach(async () => {
harness = await createApiTestHarness();
admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'user:temp_ban']);
});
afterEach(async () => {
await harness?.shutdown();
});
async function ban(target: TestAccount, reason: string): Promise<AdminAuditLog> {
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/ban`)
.header('X-Audit-Log-Reason', asReceivedHeader(reason))
.body({duration_hours: 24})
.expect(HTTP_STATUS.OK)
.execute();
const [log] = await auditLogsFor(target.userId, 'temp_ban');
return log!;
}
test('stores a UTF-8 ban reason as sent', async () => {
const target = await createTestAccount(harness);
const log = await ban(target, '§ 3 Regel – wiederholt');
expect(log.auditLogReason).toBe('§ 3 Regel – wiederholt');
});
test('appends a note to the current ban without changing the ban entry', async () => {
const target = await createTestAccount(harness);
const banLog = await ban(target, 'Original reason');
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: banLog.logId.toString(), note: 'Also sent § 4 links'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const [note] = await auditLogsFor(target.userId, 'annotate_ban');
expect(note?.auditLogReason).toBe('Also sent § 4 links');
expect(note?.adminUserId.toString()).toBe(admin.userId);
expect(Object.fromEntries(note!.metadata)).toEqual({ban_audit_log_id: banLog.logId.toString()});
const [unchanged] = await auditLogsFor(target.userId, 'temp_ban');
expect(unchanged?.auditLogReason).toBe('Original reason');
});
test('refuses a note that names another user ban', async () => {
const target = await createTestAccount(harness);
const other = await createTestAccount(harness);
await ban(target, 'Target ban');
const otherBan = await ban(other, 'Other ban');
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: otherBan.logId.toString(), note: 'Wrong ban'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(await auditLogsFor(target.userId, 'annotate_ban')).toHaveLength(0);
});
test('refuses a note once the ban has been lifted', async () => {
const target = await createTestAccount(harness);
const banLog = await ban(target, 'Lifted ban');
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: banLog.logId.toString(), note: 'Too late'})
.expect(HTTP_STATUS.CONFLICT)
.execute();
});
});
@@ -1,9 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomInt} from 'node:crypto';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {
clearTestEmails,
createTestAccount,
findLastTestEmail,
listTestEmails,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {CassandraSuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
@@ -20,7 +28,7 @@ function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}):
return {
ip,
available: true,
riskNote: 'test',
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
@@ -105,7 +113,11 @@ describe('Admin Deletion Queue', () => {
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: 2, days_until_deletion: 62})
.body({
reason_code: 2,
days_until_deletion: 62,
replace_pending_deletion_at: firstSchedule.user.pending_deletion_at,
})
.execute();
const firstDate = firstSchedule.user.pending_deletion_at.slice(0, 10);
const secondDate = secondSchedule.user.pending_deletion_at.slice(0, 10);
@@ -144,6 +156,7 @@ describe('Admin Deletion Queue', () => {
}>(harness, `${admin.token}`)
.delete(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({expected_pending_deletion_at: schedule.user.pending_deletion_at})
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
expect(
@@ -172,7 +185,7 @@ describe('Admin Deletion Queue', () => {
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(false);
});
test('moderation scheduled deletion bans email and marks IP suspicious without banning it', async () => {
test('moderation scheduled deletion bans email without banning the IP', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
@@ -189,87 +202,8 @@ describe('Admin Deletion Queue', () => {
const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(true);
expect(suspiciousIp?.source).toBe('scheduled_deletion');
expect(suspiciousIp?.sourceUserId).toBe(targetUser.userId);
});
test('moderation scheduled deletion does not mark trusted paid VPN IPs suspicious', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip, {
anonymous: {
isAnonymous: true,
providerName: 'Example Privacy Relay LLC',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
});
},
});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(suspiciousIp).toBeNull();
});
test('moderation scheduled deletion does not mark mobile carrier IPs suspicious', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip, {
asn: {
asn: 'AS64501',
number: 64501,
name: 'Test Mobile',
domain: null,
type: 'mobile',
},
mobile: {
name: 'Test Mobile',
mcc: '001',
mnc: '01',
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: true,
isSatellite: false,
},
});
},
});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(suspiciousIp).toBeNull();
});
test('rejects a scheduled deletion reason code outside the registry', async () => {
const admin = await createTestAccount(harness);
@@ -317,4 +251,155 @@ describe('Admin Deletion Queue', () => {
.executeWithResponse();
expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true);
});
describe('naming the deletion', () => {
interface ScheduledUser {
user: {
pending_deletion_at: string | null;
deletion_scheduled_by: string | null;
deletion_scheduled_at: string | null;
deletion_audit_log_reason: string | null;
};
}
async function scheduleAs(admin: TestAccount, target: TestAccount, reasonCode: number, reason: string) {
return createBuilder<ScheduledUser>(harness, admin.token)
.put(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', reason)
.body({reason_code: reasonCode, days_until_deletion: 60})
.expect(HTTP_STATUS.OK)
.execute();
}
async function createAdmin(acls: Array<string> = []): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'user:delete', ...acls]);
}
test('records who scheduled the deletion and shows the private reason to audit log viewers', async () => {
const admin = await createAdmin(['audit_log:view']);
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(admin, target, DeletionReasons.SPAM, 'Batch review');
expect(scheduled.user.deletion_scheduled_by).toBe(admin.userId);
expect(scheduled.user.deletion_scheduled_at).not.toBeNull();
expect(scheduled.user.deletion_audit_log_reason).toBe('Batch review');
const other = await createAdmin(['user:lookup']);
const viewed = await createBuilder<{users: Array<ScheduledUser['user']>}>(harness, other.token)
.get(`/admin/users/${target.userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(viewed.users[0]?.deletion_scheduled_by).toBe(admin.userId);
expect(viewed.users[0]?.deletion_audit_log_reason).toBeNull();
});
test('a second schedule without naming the pending deletion is refused and keeps it', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
await createBuilder(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
await createBuilder(harness, first.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.OTHER, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt?.toISOString()).toBe(scheduled.user.pending_deletion_at);
expect(user?.deletionScheduledBy?.toString()).toBe(first.userId);
});
test('replacing a named deletion records the deletion it replaced', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
const replaced = await createBuilder<ScheduledUser>(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({
reason_code: DeletionReasons.OTHER,
days_until_deletion: 90,
replace_pending_deletion_at: scheduled.user.pending_deletion_at,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(replaced.user.deletion_scheduled_by).toBe(second.userId);
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) =>
entry.action === 'schedule_deletion' &&
entry.targetId.toString() === target.userId &&
entry.adminUserId.toString() === second.userId,
);
expect(log?.metadata.get('replaced_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('replaced_scheduled_by')).toBe(first.userId);
expect(log?.metadata.get('replaced_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('pending_deletion_at')).toBe(replaced.user.pending_deletion_at);
});
test('cancel requires the pending deletion it cancels', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await scheduleAs(admin, target, DeletionReasons.SPAM, 'Review');
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date(Date.now() + 86_400_000).toISOString()})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt).not.toBeNull();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
});
test('cancel refuses when nothing is pending', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date().toISOString()})
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PENDING_DELETION')
.execute();
});
test('cancel records whose deletion it cancelled and emails only on request', async () => {
const scheduler = await createAdmin();
const canceller = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review');
await clearTestEmails(harness);
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: scheduled.user.pending_deletion_at})
.expect(HTTP_STATUS.OK)
.execute();
expect(
findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification'),
).toBeNull();
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) => entry.action === 'cancel_deletion' && entry.targetId.toString() === target.userId,
);
expect(log?.metadata.get('cancelled_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('cancelled_scheduled_by')).toBe(scheduler.userId);
expect(log?.metadata.get('cancelled_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('notify_user')).toBe('false');
const rescheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review again');
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: rescheduled.user.pending_deletion_at, notify_user: true})
.expect(HTTP_STATUS.OK)
.execute();
const email = findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification');
expect(email).not.toBeNull();
expect(JSON.stringify(email)).not.toContain('Private cancel note');
});
});
});
@@ -80,7 +80,6 @@ describe('service level guards for synthetic accounts', () => {
guildId: GUILD_ID,
skipBanCheck: true,
skipGuildLimitCheck: true,
skipRiskGate: true,
requestCache: new Map(),
} as never,
unusableDependency() as never,
@@ -37,7 +37,7 @@ export const BanAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'list_blocklists',
targetType: 'blocklist',
targetId: '0',
metadata: {result_count: '9'},
metadata: {result_count: '8'},
},
};
},
@@ -20,7 +20,7 @@ import {
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {getAdminRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -340,6 +340,34 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'POST',
route: '/admin/users/:user_id/ban/notes',
auditLogReason: 'Coverage ban note',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context)
.put(`/admin/users/${target.userId}/ban`)
.body({duration_hours: 24, reason: 'Coverage ban'})
.execute();
const banLog = (await getAdminRepository().listAllAuditLogsPaginated(1000)).find(
(log) => log.action === 'temp_ban' && log.targetId.toString() === target.userId,
);
return {
request: {
path: `/admin/users/${target.userId}/ban/notes`,
body: {ban_audit_log_id: banLog!.logId.toString(), note: 'Coverage ban note'},
expectStatus: 204,
},
expected: {
action: 'annotate_ban',
targetType: 'user',
targetId: target.userId,
metadata: {ban_audit_log_id: banLog!.logId.toString()},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/deletion',
@@ -354,7 +382,11 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'schedule_deletion',
targetType: 'user',
targetId: target.userId,
metadata: {days: '30', reason_code: DeletionReasons.USER_REQUESTED.toString()},
metadata: {
days: '30',
reason_code: DeletionReasons.USER_REQUESTED.toString(),
pending_deletion_at: expect.any(String),
},
},
};
},
@@ -368,13 +400,23 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 30})
.execute();
const pendingDeletionAt = (await loadUser(target)).pendingDeletionAt!.toISOString();
return {
request: {path: `/admin/users/${target.userId}/deletion`},
request: {
path: `/admin/users/${target.userId}/deletion`,
body: {expected_pending_deletion_at: pendingDeletionAt},
},
expected: {
action: 'cancel_deletion',
targetType: 'user',
targetId: target.userId,
metadata: {},
metadata: {
cancelled_pending_deletion_at: pendingDeletionAt,
cancelled_scheduled_by: context.admin.userId,
cancelled_scheduled_at: expect.any(String),
cancelled_reason_code: DeletionReasons.USER_REQUESTED.toString(),
notify_user: 'false',
},
},
};
},
+43 -23
View File
@@ -2,15 +2,27 @@
import {randomUUID} from 'node:crypto';
import {ensureDeletionQueueState} from '@app/api/app/DeletionQueueStartup';
import {renderPhoneRpcMetrics, setPhoneRpcConnection} from '@app/api/auth/PhoneVerificationClient';
import type {APIConfig} from '@app/api/config/APIConfig';
import {hasDatabaseQueryExecutor, setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {GuildDataRepository} from '@app/api/guild/repositories/GuildDataRepository';
import type {ILogger} from '@app/api/ILogger';
import {
jetStreamActivityPublisher,
renderActivityMetrics,
shutdownActivityEvents,
startActivityEvents,
} from '@app/api/infrastructure/activity/ActivityEvents';
import {
renderSharedListMetrics,
startSharedListWatch,
stopSharedListWatch,
} from '@app/api/infrastructure/activity/SharedLists';
import {shutdownStorageChangeFeed} from '@app/api/infrastructure/StorageServiceFactory';
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '@app/api/middleware/AbusiveIpAutoBanner';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {startRequestErrorTelemetry, stopRequestErrorTelemetry} from '@app/api/middleware/RequestErrorTelemetry';
import {initializeServiceSingletons, shutdownReportService} from '@app/api/middleware/ServiceMiddleware';
import {
closeOwnedKVClient,
@@ -29,7 +41,6 @@ import {
shutdownInstanceConfigRepository,
shutdownServiceSingletons,
} from '@app/api/middleware/ServiceSingletons';
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
import {ensureApnsSigningKey} from '@app/api/push/ApnsPushService';
import {initializeSearch, shutdownSearch} from '@app/api/SearchFactory';
import {warmupAdminSearchIndexes} from '@app/api/search/SearchWarmup';
@@ -37,12 +48,20 @@ import {VisionarySlotInitializer} from '@app/api/stripe/VisionarySlotInitializer
import {VoiceDataInitializer} from '@app/api/voice/VoiceDataInitializer';
import {JetStreamWorkerQueue} from '@app/api/worker/JetStreamWorkerQueue';
import {WorkerService} from '@app/api/worker/WorkerService';
import {registerMetricsSection} from '@fluxer/hono/src/middleware/Metrics';
import type {JetStreamClient} from '@nats-io/jetstream';
import {initCassandra, shutdownCassandra} from '@pkgs/cassandra/src/Client';
import {ensureGeoipDatabaseOnStartup} from '@pkgs/geoip/src/GeoipStartup';
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
let jsConnectionManager: JetStreamConnectionManager | null = null;
const unregisterMetricsSections: Array<() => void> = [];
export function getActivityJetStream(): JetStreamClient | null {
if (!jsConnectionManager || jsConnectionManager.isClosed()) return null;
return jsConnectionManager.getJetStreamClient();
}
interface RefreshCacheLifecycle {
initialize(): Promise<void>;
@@ -103,11 +122,10 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
{
maxmind_db_path: geoipStartupResult.maxmindDbPath,
city: geoipStartupResult.city,
asn: geoipStartupResult.asn,
s3_bucket: geoipStartupResult.bucket,
s3_key: geoipStartupResult.key,
},
'GeoIP databases downloaded from S3',
'GeoIP database downloaded from S3',
);
}
if (config.database.backend === 'postgres' && !hasDatabaseQueryExecutor()) {
@@ -135,13 +153,6 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
const kvClient = getKVClient();
ipBanCache.setRefreshSubscriber(kvClient);
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
await startAbuseReplicationSubscriber(kvClient);
logger.info('Abusive-IP auto-banner replication started');
if (config.torExitList.enabled) {
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
}
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
urlBlocklistCache.setRefreshSubscriber(kvClient);
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
@@ -172,6 +183,22 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
await workerQueue.ensureStream();
setInjectedWorkerService(new WorkerService(workerQueue, getSnowflakeService(), new JobLedgerRepository()));
logger.info('JetStream worker service initialized');
const connection = jsConnectionManager;
await startActivityEvents({
publisher: jetStreamActivityPublisher(connection.getJetStreamClient()),
kv: kvClient,
jsm: await connection.getJetStreamManager(),
spoolWhileMissing: !config.instance.selfHosted,
});
startRequestErrorTelemetry();
startSharedListWatch(connection.getJetStreamClient());
setPhoneRpcConnection(() => (connection.isClosed() ? null : connection.getConnection()));
unregisterMetricsSections.push(
registerMetricsSection(renderActivityMetrics),
registerMetricsSection(renderSharedListMetrics),
registerMetricsSection(renderPhoneRpcMetrics),
);
logger.info('Activity events initialized');
}
await ensureDeletionQueueState(getKVAccountDeletionQueue(), logger);
logger.info('Initializing search indexes...');
@@ -253,6 +280,11 @@ export function createShutdown(config: APIConfig, logger: ILogger): () => Promis
} catch (error) {
logger.error({error}, 'Error shutting down voice resources');
}
stopRequestErrorTelemetry();
stopSharedListWatch();
setPhoneRpcConnection(null);
for (const unregister of unregisterMetricsSections.splice(0)) unregister();
await shutdownActivityEvents();
if (jsConnectionManager) {
try {
await jsConnectionManager.drain();
@@ -269,12 +301,6 @@ export function createShutdown(config: APIConfig, logger: ILogger): () => Promis
} catch (error) {
logger.error({error}, 'Error shutting down search service');
}
try {
await stopAbuseReplicationSubscriber();
logger.info('Abusive-IP auto-banner replication stopped');
} catch (error) {
logger.error({error}, 'Error stopping abusive-IP auto-banner replication');
}
await Promise.all([
shutdownRefreshCaches(logger),
shutdownServiceSingletons()
@@ -285,12 +311,6 @@ export function createShutdown(config: APIConfig, logger: ILogger): () => Promis
logger.error({error}, 'Error shutting down service singletons');
}),
]);
try {
await torExitListCache.shutdown();
logger.info('Tor exit list cache shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Tor exit list cache');
}
try {
await shutdownInstanceConfigRepository();
logger.info('Instance config repository shut down');
+3 -30
View File
@@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerAdminControllers} from '@app/api/admin/controllers/index';
import {getActivityJetStream} from '@app/api/app/APILifecycle';
import {AttachmentController} from '@app/api/attachment/AttachmentController';
import {AuthController} from '@app/api/auth/AuthController';
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
import {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
import {Config} from '@app/api/Config';
import {ChannelController} from '@app/api/channel/ChannelController';
import type {APIConfig} from '@app/api/config/APIConfig';
import {ConnectionController} from '@app/api/connection/ConnectionController';
@@ -21,17 +21,12 @@ import {GifController} from '@app/api/gif/GifController';
import {GuildController} from '@app/api/guild/GuildController';
import {InstanceController} from '@app/api/instance/InstanceController';
import {InviteController} from '@app/api/invite/InviteController';
import {Logger} from '@app/api/Logger';
import {getInboundSmsChallengeServiceInstance, getUserRepositoryInstance} from '@app/api/middleware/ServiceMiddleware';
import {getGatewayService} from '@app/api/middleware/ServiceRegistry';
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
import {OAuth2ApplicationsController} from '@app/api/oauth/OAuth2ApplicationsController';
import {OAuth2Controller} from '@app/api/oauth/OAuth2Controller';
import {OpenAPIController} from '@app/api/openapi/OpenAPIController';
import {PremiumController} from '@app/api/premium/PremiumController';
import {ReadStateController} from '@app/api/read_state/ReadStateController';
import {ReportController} from '@app/api/report/ReportController';
import {installTwilioInboundSmsWebhook} from '@app/api/risk/TwilioInboundSmsWebhook';
import {InternalRpcController} from '@app/api/rpc/InternalRpcController';
import {SearchController} from '@app/api/search/controllers/SearchController';
import {StripeController} from '@app/api/stripe/StripeController';
@@ -40,6 +35,7 @@ import {ThemeController} from '@app/api/theme/ThemeController';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {UnfurlController} from '@app/api/unfurl/UnfurlController';
import {UserController} from '@app/api/user/controllers/UserController';
import {installSmsWebhookForwarder} from '@app/api/webhook/SmsWebhookForwarder';
import {WebhookController} from '@app/api/webhook/WebhookController';
export function registerControllers(routes: HonoApp, config: APIConfig): void {
@@ -72,9 +68,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
TestHarnessController(routes);
}
UserController(routes);
if (config.sms.enabled) {
registerInboundSmsWebhook(routes);
}
installSmsWebhookForwarder(routes, getActivityJetStream);
WebhookController(routes);
OAuth2Controller(routes);
OAuth2ApplicationsController(routes);
@@ -84,24 +78,3 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
}
StripeController(routes);
}
function registerInboundSmsWebhook(routes: HonoApp): void {
const authToken = Config.sms.inboundWebhookAuthToken;
const publicWebhookUrl = Config.sms.inboundWebhookPublicUrl;
if (!authToken || !publicWebhookUrl) {
Logger.warn(
{},
'Twilio inbound SMS webhook not configured (need integrations.sms.inbound_webhook_auth_token + integrations.sms.inbound_webhook_public_url); skipping installation',
);
return;
}
installTwilioInboundSmsWebhook(routes, {
authToken,
publicWebhookUrl,
inboundSmsChallengeService: getInboundSmsChallengeServiceInstance(),
userRepository: getUserRepositoryInstance(),
gatewayService: getGatewayService(),
cacheService: getCacheService(),
});
Logger.info({publicWebhookUrl}, 'Twilio inbound SMS webhook installed');
}
+5 -16
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ILogger} from '@app/api/ILogger';
import {ClientErrorAbuseSignalMiddleware} from '@app/api/middleware/AbusiveIpAutoBanner';
import {ActivityContextMiddleware} from '@app/api/infrastructure/activity/ActivityMeta';
import {AuditLogMiddleware} from '@app/api/middleware/AuditLogMiddleware';
import {ConcurrencyLimitMiddleware} from '@app/api/middleware/ConcurrencyLimitMiddleware';
import ContentFilterMiddleware from '@app/api/middleware/ContentFilterMiddleware';
@@ -9,9 +9,9 @@ import {GuildAvailabilityMiddleware} from '@app/api/middleware/GuildAvailability
import {IpBanMiddleware} from '@app/api/middleware/IpBanMiddleware';
import {LocaleMiddleware} from '@app/api/middleware/LocaleMiddleware';
import {RequestCacheMiddleware} from '@app/api/middleware/RequestCacheMiddleware';
import {RequestErrorTelemetry} from '@app/api/middleware/RequestErrorTelemetry';
import {RequireClientIpMiddleware} from '@app/api/middleware/RequireClientIpMiddleware';
import {ServiceMiddleware} from '@app/api/middleware/ServiceMiddleware';
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
import {TrustedClientIpHeaderMiddleware} from '@app/api/middleware/TrustedClientIpHeaderMiddleware';
import {UserMiddleware} from '@app/api/middleware/UserMiddleware';
import type {HonoApp} from '@app/api/types/HonoEnv';
@@ -29,19 +29,10 @@ interface MiddlewarePipelineOptions {
trustClientIpHeader: boolean;
clientIpHeaderName?: string;
maxInflightRequests: number;
torExitBlockingEnabled: boolean;
}
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
const {
logger,
nodeEnv,
corsOrigins,
trustClientIpHeader,
clientIpHeaderName,
maxInflightRequests,
torExitBlockingEnabled,
} = options;
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
@@ -85,7 +76,7 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
skip: ['/_health'],
}),
);
routes.use(ClientErrorAbuseSignalMiddleware);
routes.use(RequestErrorTelemetry);
routes.use(RequestCacheMiddleware);
if (nodeEnv === 'production') {
routes.use('*', async (ctx, next) => {
@@ -109,11 +100,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
}),
);
}
if (torExitBlockingEnabled) {
routes.use(TorExitMiddleware);
}
routes.use(AuditLogMiddleware);
routes.use(RequireClientIpMiddleware());
routes.use(ActivityContextMiddleware);
routes.use(ServiceMiddleware);
routes.use(UserMiddleware);
routes.use(ContentFilterMiddleware);
@@ -1,32 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
logger: new NoopLogger(),
nodeEnv: 'test',
corsOrigins: ['http://localhost:3000'],
trustClientIpHeader: true,
clientIpHeaderName: 'x-forwarded-for',
maxInflightRequests: 100,
torExitBlockingEnabled,
});
return routes.routes.map((route) => route.handler);
}
describe('tor exit blocking in the middleware pipeline', () => {
it('registers the tor exit middleware when the switch is on', () => {
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
});
it('leaves the tor exit middleware unregistered when the switch is off', () => {
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
});
});
@@ -21,7 +21,6 @@ function createProductionApp(): Hono<HonoEnv> {
trustClientIpHeader: true,
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
maxInflightRequests: 100,
torExitBlockingEnabled: false,
});
routes.onError(AppErrorHandler);
routes.notFound(AppNotFoundHandler);
+6 -6
View File
@@ -109,8 +109,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/register',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
CaptchaMiddleware,
Validator('json', RegisterRequest),
OpenAPI({
operationId: 'register_account',
@@ -120,7 +120,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
'Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').register({
@@ -134,8 +134,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/login',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
CaptchaMiddleware,
Validator('json', LoginRequest),
OpenAPI({
operationId: 'login_user',
@@ -145,7 +145,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').login({
@@ -240,8 +240,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/forgot',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
CaptchaMiddleware,
Validator('json', ForgotPasswordRequest),
OpenAPI({
operationId: 'forgot_password',
@@ -251,7 +251,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires a solved captcha challenge (X-Captcha-Token).",
}),
async (ctx) => {
await ctx.get('authRequestService').forgotPassword({
+26 -29
View File
@@ -14,6 +14,7 @@ import {
createUserID,
} from '@app/api/BrandedTypes';
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import {
REGISTRATION_PENDING_APPROVAL_TRAIT,
@@ -107,6 +108,16 @@ function getTokenCacheKey(token: string): string {
return `ip-auth-token:${token}`;
}
function emitLogin(user: User, ok: boolean, details: {failure?: string; mfa?: boolean; newIp?: boolean} = {}): void {
void emitActivity('login', user.id.toString(), {
user_id: user.id.toString(),
ok,
failure: details.failure ?? null,
mfa: details.mfa ?? false,
new_ip: details.newIp ?? false,
});
}
export async function resendIpAuthorization(
ctx: ApiContext,
ticket: string,
@@ -179,6 +190,7 @@ export async function completeIpAuthorization(
AuthUtility.assertNonBotUser(ctx, user);
await users.createAuthorizedIp(user.id, payload.origin.ip);
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
emitLogin(user, true, {newIp: true});
await cache.delete(cacheKey);
await cache.delete(getTokenCacheKey(token));
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
@@ -222,6 +234,7 @@ export async function login(
AuthUtility.assertNonBotUser(ctx, user);
if (!user.passwordHash) {
await AuthPassword.verifyPassword(ctx, {password: data.password, passwordHash: DUMMY_ARGON2_HASH});
emitLogin(user, false, {failure: 'no_password'});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
@@ -232,39 +245,17 @@ export async function login(
passwordHash: user.passwordHash,
});
if (!isMatch) {
emitLogin(user, false, {failure: 'bad_password'});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
}
let currentUser = await AuthUtility.handleBanStatus(ctx, user);
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
currentUser = await users.patchUpsert(
currentUser.id,
{
flags: updatedFlags,
},
currentUser.toRow(),
);
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
}
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
const pendingDeletionAt = currentUser.pendingDeletionAt;
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
currentUser = await users.updateDeletionSchedule(currentUser, {
flags: updatedFlags,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
});
if (pendingDeletionAt) {
await users.removePendingDeletion(currentUser.id, pendingDeletionAt);
}
await kvDeletionQueue.removeFromQueue(currentUser.id);
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
}
const currentUser = await AuthUtility.reactivateOnSignIn(
ctx,
await AuthUtility.handleBanStatus(ctx, user),
kvDeletionQueue,
);
if (currentUser.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) {
throw new RegistrationPendingApprovalError();
}
@@ -275,8 +266,10 @@ export async function login(
currentUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
currentUser.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN);
const isAppStoreReviewer = (currentUser.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
let newIp = false;
if (!hasMfa && !isAppStoreReviewer) {
const isIpAuthorized = await users.checkIpAuthorized(currentUser.id, clientIp);
newIp = !isIpAuthorized;
if (!isIpAuthorized) {
const instanceConfigRepository = getInstanceConfigRepository();
const [integrationsConfig, effectiveEmailConfig] = await Promise.all([
@@ -318,6 +311,7 @@ export async function login(
clientLocation,
currentUser.locale,
);
emitLogin(currentUser, false, {failure: 'ip_authorization_required', newIp: true});
throw new IpAuthorizationRequiredError({
ticket,
email: currentUser.email!,
@@ -345,6 +339,7 @@ export async function login(
user: currentUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
emitLogin(currentUser, true, {newIp});
return {
user_id: currentUser.id.toString(),
token,
@@ -428,7 +423,9 @@ export async function completeMfaLogin(
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
return createLoginSession(ctx, user, request);
const session = await createLoginSession(ctx, user, request);
emitLogin(user, true, {mfa: true});
return session;
}
export async function loginMfaWebAuthn(
+100 -584
View File
@@ -1,51 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {phonePrefixBanCache} from '@app/api/auth/PhonePrefixBanCache';
import {requiresInboundPhoneVerification} from '@app/api/auth/PhoneVerificationPrefixPolicy';
import {PhoneVerificationReuseStore} from '@app/api/auth/PhoneVerificationReuseStore';
import type {IssuedChallenge} from '@app/api/auth/services/InboundSmsChallengeService';
import type {PhoneAttemptInboundReason, PhoneAttemptRejectReason} from '@app/api/auth/services/PhoneLookupRepository';
import {
errorForPhoneReply,
getPhoneVerificationClient,
type PhoneRpcRequestInfo,
} from '@app/api/auth/PhoneVerificationClient';
import type {UserID} from '@app/api/BrandedTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {Challenge, PhoneChannel} from '@app/api/infrastructure/activity/Contract.generated';
import {Logger} from '@app/api/Logger';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {mapUserToPartialResponse, mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {PHONE_ADD_CLEARABLE_FLAGS, UserFlags} from '@fluxer/constants/src/UserConstants';
import {accountStateDepsFromContext, applyPhoneVerified, outcomeOf} from '@app/api/user/services/AccountStateApplier';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {BotUserAuthEndpointAccessDeniedError} from '@fluxer/errors/src/domains/auth/BotUserAuthEndpointAccessDeniedError';
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {InvalidPhoneVerificationCodeError} from '@fluxer/errors/src/domains/auth/InvalidPhoneVerificationCodeError';
import {PhoneAlreadyUsedError} from '@fluxer/errors/src/domains/auth/PhoneAlreadyUsedError';
import {PhoneCountryNotSupportedError} from '@fluxer/errors/src/domains/auth/PhoneCountryNotSupportedError';
import {PhoneInboundVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneInboundVerificationRequiredError';
import {PhoneLookupUnavailableError} from '@fluxer/errors/src/domains/auth/PhoneLookupUnavailableError';
import {PhoneNumberNotInServiceError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotInServiceError';
import {PhoneNumberNotMobileError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotMobileError';
import {PhoneVerificationNeedsReviewError} from '@fluxer/errors/src/domains/auth/PhoneVerificationNeedsReviewError';
import {PhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneVerificationRequiredError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
import {PHONE_E164_REGEX} from '@fluxer/schema/src/primitives/UserValidators';
import type {RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import {
ACCEPTED_PHONE_LINE_TYPES,
getSmsPumpingRiskThreshold,
HARD_REJECT_PHONE_LINE_TYPES,
VOIP_PHONE_LINE_TYPES,
} from '@pkgs/sms/src/PhoneLookupTypes';
import {SmsVerificationStartError, TwilioVerificationRateLimitError} from '@pkgs/sms/src/providers/TwilioSmsProvider';
import type {SmsVerificationStartOptions} from '@pkgs/sms/src/SmsVerificationTypes';
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const ACCOUNT_SMS_SEND_LIMIT = {maxAttempts: 3, windowMs: 6 * HOUR_MS};
const PHONE_SMS_SEND_LIMIT = {maxAttempts: 3, windowMs: 5 * DAY_MS};
const TWILIO_PHONE_PREFIX_RATE_LIMIT_LENGTH = 6;
type PhoneVerificationStartResult =
| {channel: 'sms'}
@@ -54,578 +28,120 @@ type PhoneVerificationStartResult =
challengeCode: string;
ourNumber: string;
expiresAt: Date;
reason: PhoneAttemptInboundReason;
};
interface IssuedChallenge {
challengeCode: string;
ourNumber: string;
expiresAt: Date;
}
interface SendPhoneVerificationOptions {
clientIp?: string;
channel?: 'sms' | 'inbound_challenge';
clientIp: string;
channel?: PhoneChannel;
hasCaptchaToken: boolean;
verifyCaptcha: () => Promise<boolean>;
}
function assertNonBotUser(user: User): void {
if (user.isBot) {
throw new BotUserAuthEndpointAccessDeniedError();
}
}
function reuseStoreFor(ctx: ApiContext): PhoneVerificationReuseStore {
return new PhoneVerificationReuseStore(ctx.services.cache);
}
export async function startInboundPhoneChallenge(ctx: ApiContext, userId: UserID): Promise<IssuedChallenge> {
const {inboundSmsChallenge, users, config} = ctx.services;
const ourNumber = config.sms.inboundChallengeNumber;
if (!inboundSmsChallenge || !ourNumber) {
Logger.warn(
{userId: String(userId)},
'Inbound SMS challenge requested but FLUXER_SMS_INBOUND_CHALLENGE_NUMBER is unset',
);
throw new SmsVerificationUnavailableError();
}
const user = await users.findUnique(userId);
if (!user) throw new UnknownUserError();
assertNonBotUser(user);
return inboundSmsChallenge.issueChallenge({userId, ourNumber});
}
class PhoneInboundChallengeRequiredError extends Error {
constructor(readonly reason: PhoneAttemptInboundReason) {
super(`Phone verification must be completed via inbound challenge: ${reason}`);
this.name = 'PhoneInboundChallengeRequiredError';
}
}
interface ValidatePhoneOptions {
inboundCapable?: boolean;
}
async function validatePhoneOrThrow(ctx: ApiContext, phone: string, options: ValidatePhoneOptions = {}): Promise<void> {
assertPhoneFormatOrThrow(phone);
const {phoneLookup, sms} = ctx.services;
const cached = (await phoneLookup?.getCachedLookup(phone)) ?? null;
const lookup = cached ?? (await sms.lookupPhone(phone));
const cacheHit = cached !== null;
if (!cacheHit && lookup) {
await phoneLookup?.setCachedLookup(phone, lookup);
}
const verdict = computePhoneVerdict(lookup, phone);
await phoneLookup?.recordAttempt({
phone,
lookup,
verdict: verdict.verdict,
rejectReason: verdict.verdict === 'reject' ? verdict.rejectReason : null,
inboundReason: verdict.verdict === 'require_inbound' ? verdict.inboundReason : null,
lookupCacheHit: cacheHit,
});
if (verdict.verdict === 'reject') {
if (verdict.rejectReason === 'lookup_unavailable') {
Logger.error({phone}, 'Phone lookup unavailable — rejecting attachment (fail-closed)');
} else {
Logger.info(
{
phone,
rejectReason: verdict.rejectReason,
lineType: lookup?.lineType ?? null,
carrier: lookup?.carrierName ?? null,
country: lookup?.countryCode ?? null,
smsPumpingRiskScore: lookup?.smsPumpingRiskScore ?? null,
lookupCacheHit: cacheHit,
},
'Phone verification rejected at gate',
);
}
throw errorForPhoneRejectReason(verdict.rejectReason);
}
if (verdict.verdict === 'require_inbound') {
Logger.info(
{
phone,
inboundReason: verdict.inboundReason,
lineType: lookup?.lineType ?? null,
country: lookup?.countryCode ?? null,
},
'Phone verification routed to inbound challenge',
);
if (options.inboundCapable) {
throw new PhoneInboundChallengeRequiredError(verdict.inboundReason);
}
throw new PhoneInboundVerificationRequiredError();
}
}
function assertPhoneFormatOrThrow(phone: string): void {
function assertPhoneFormat(phone: string): void {
if (!PHONE_E164_REGEX.test(phone)) {
throw new InvalidPhoneNumberError();
}
if (phonePrefixBanCache.isBlocked(phone)) {
throw new PhoneCountryNotSupportedError();
}
}
async function issueInboundChallengeOrThrow(
ctx: ApiContext,
userId: UserID,
phone: string,
reason: PhoneAttemptInboundReason,
): Promise<PhoneVerificationStartResult> {
try {
const challenge = await startInboundPhoneChallenge(ctx, userId);
return {
channel: 'inbound_challenge',
challengeCode: challenge.challengeCode,
ourNumber: challenge.ourNumber,
expiresAt: challenge.expiresAt,
reason,
};
} catch (error) {
Logger.error({phone, userId: String(userId), reason, error}, 'Inbound phone challenge required but unavailable');
throw new SmsVerificationUnavailableError();
}
async function loadRequestingUser(ctx: ApiContext, userId: UserID): Promise<User> {
const user = await ctx.services.users.findUnique(userId);
if (!user) throw new UnknownUserError();
if (user.isBot) throw new BotUserAuthEndpointAccessDeniedError();
return user;
}
function requestInfo(ctx: ApiContext, userId: UserID, phone: string): PhoneRpcRequestInfo {
return {userId: userId.toString(), phone, requestId: ctx.request.requestId};
}
function toIssuedChallenge(challenge: Challenge): IssuedChallenge {
return {
challengeCode: challenge.challenge_code,
ourNumber: challenge.our_number,
expiresAt: new Date(challenge.expires_at_ms),
};
}
export async function sendPhoneVerificationCode(
ctx: ApiContext,
phone: string,
userId: UserID | null,
options: SendPhoneVerificationOptions = {},
): Promise<PhoneVerificationStartResult> {
assertPhoneFormatOrThrow(phone);
const {users, sms} = ctx.services;
let requestingUser: User | null = null;
if (userId) {
requestingUser = await users.findUnique(userId);
if (requestingUser) {
assertNonBotUser(requestingUser);
}
}
const accountForcedInbound =
requestingUser !== null && (requestingUser.flags & UserFlags.FORCE_INBOUND_PHONE_VERIFICATION) !== 0n;
const prefixForcedInbound =
userId !== null && requestingUser !== null && shouldRequireInboundPhoneChallenge(phone, requestingUser);
const requireInbound = options.channel === 'inbound_challenge' || accountForcedInbound || prefixForcedInbound;
if (requireInbound && userId) {
const inboundReason: PhoneAttemptInboundReason = accountForcedInbound ? 'account_forced' : 'expensive_destination';
return await issueInboundChallengeOrThrow(ctx, userId, phone, inboundReason);
}
const riskInput = {
userId: userId ? userId.toString() : null,
clientIp: options.clientIp ?? null,
phone,
};
const preRisk = await ctx.services.phoneAttemptRisk.evaluate(riskInput);
if (preRisk.decision === 'hard_block') {
Logger.warn({...riskInput, reason: preRisk.reason}, 'phone_attempt_risk hard_block at send');
throw createPhoneRateLimitError(
{
retryAfter: 24 * 60 * 60,
retryAfterDecimal: 24 * 60 * 60,
limit: 1,
resetTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
resetAfterDecimal: 24 * 60 * 60,
},
'user',
);
}
if (preRisk.decision === 'require_inbound' && userId) {
return await issueInboundChallengeOrThrow(ctx, userId, phone, 'behavioural_risk');
}
if (preRisk.decision === 'require_captcha') {
throw new CaptchaVerificationRequiredError();
}
await throwIfProviderCooldownActive(ctx, phone, userId);
let rejectedAttempt = false;
let lookupCountry: string | null = null;
try {
try {
await validatePhoneOrThrow(ctx, phone, {inboundCapable: userId !== null});
} catch (error) {
if (error instanceof PhoneInboundChallengeRequiredError && userId !== null) {
await ctx.services.phoneAttemptRisk.record({...riskInput, rejected: false});
return await issueInboundChallengeOrThrow(ctx, userId, phone, error.reason);
}
rejectedAttempt = !(error instanceof PhoneLookupUnavailableError);
throw error;
}
const cached = await ctx.services.phoneLookup?.getCachedLookup(phone);
lookupCountry = cached?.countryCode ?? null;
if (await reuseStoreFor(ctx).hasReachedVerificationLimit(phone)) {
rejectedAttempt = true;
throw new PhoneAlreadyUsedError();
}
await enforceSmsSendProtections(ctx, phone, requestingUser);
try {
const startOptions = buildVerificationStartOptions(phone, userId, options);
await sms.startVerificationWithResult(phone, startOptions);
return {channel: 'sms'};
} catch (error) {
if (error instanceof TwilioVerificationRateLimitError) {
rejectedAttempt = true;
await recordProviderCooldown(ctx, phone, userId, error);
throw error;
}
if (error instanceof SmsVerificationStartError) {
rejectedAttempt = true;
Logger.warn(
{phone, userId: userId ? String(userId) : null, context: error.sentryContext},
'Phone verification send unavailable',
);
throw new SmsVerificationUnavailableError();
}
rejectedAttempt = true;
throw error;
}
} finally {
try {
await ctx.services.phoneAttemptRisk.record({
...riskInput,
countryCode: lookupCountry,
rejected: rejectedAttempt,
});
} catch (error) {
Logger.warn({error}, 'phone_attempt_risk record failed (non-fatal)');
}
}
}
function buildVerificationStartOptions(
phone: string,
userId: UserID | null,
userId: UserID,
options: SendPhoneVerificationOptions,
): SmsVerificationStartOptions {
const rateLimits: Record<string, string> = {
fluxer_phone_prefix: getRateLimitPhonePrefix(phone),
};
if (userId) {
rateLimits.fluxer_user_id = userId.toString();
): Promise<PhoneVerificationStartResult> {
assertPhoneFormat(phone);
const user = await loadRequestingUser(ctx, userId);
const start = (captchaPassed: boolean) =>
getPhoneVerificationClient().start(
{
user_id: user.id.toString(),
user_flags: user.flags.toString(),
has_verified_phone: user.hasVerifiedPhone,
phone,
requested_channel: options.channel ?? null,
client_ip: options.clientIp,
captcha_passed: captchaPassed,
},
requestInfo(ctx, userId, phone),
);
const captchaPassed = options.hasCaptchaToken && (await options.verifyCaptcha());
const reply = await start(captchaPassed);
if (reply.result === 'error' && reply.code === 'captcha_required' && !captchaPassed) {
await options.verifyCaptcha();
Logger.warn({userId: userId.toString()}, 'Phone verification asked for a captcha without a captcha check');
}
if (options.clientIp) {
rateLimits.fluxer_client_ip = options.clientIp;
switch (reply.result) {
case 'sms_sent':
return {channel: 'sms'};
case 'inbound_challenge':
return {channel: 'inbound_challenge', ...toIssuedChallenge(reply)};
case 'error':
throw errorForPhoneReply(reply);
}
return {
rateLimits,
};
}
function shouldRequireInboundPhoneChallenge(phone: string, user: User): boolean {
if (user.hasVerifiedPhone) return false;
return requiresInboundPhoneVerification(phone);
}
function getRateLimitPhonePrefix(phone: string): string {
return phone.slice(0, TWILIO_PHONE_PREFIX_RATE_LIMIT_LENGTH);
export async function startInboundPhoneChallenge(ctx: ApiContext, userId: UserID): Promise<IssuedChallenge> {
const user = await loadRequestingUser(ctx, userId);
const reply = await getPhoneVerificationClient().challenge(
{user_id: user.id.toString()},
requestInfo(ctx, userId, ''),
);
if (reply.result === 'error') throw errorForPhoneReply(reply);
return toIssuedChallenge(reply);
}
export async function verifyPhoneCode(ctx: ApiContext, phone: string, code: string, userId: UserID): Promise<void> {
await validatePhoneOrThrow(ctx, phone);
const {sms} = ctx.services;
let isValid: boolean;
try {
isValid = await sms.checkVerification(phone, code);
} catch (error) {
if (error instanceof TwilioVerificationRateLimitError) {
await recordProviderCooldown(ctx, phone, userId, error);
}
throw error;
}
if (!isValid) {
throw new InvalidPhoneVerificationCodeError();
}
await attachVerifiedPhoneToAccount(ctx, userId, phone);
}
async function attachVerifiedPhoneToAccount(ctx: ApiContext, userId: UserID, phone: string): Promise<void> {
const {users, gateway, contactChangeLog} = ctx.services;
if (!(await reuseStoreFor(ctx).claimVerificationSlot(phone))) {
throw new PhoneAlreadyUsedError();
}
const user = await users.findUnique(userId);
if (!user) {
throw new PhoneVerificationRequiredError();
}
assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
throw new PhoneVerificationRequiredError();
}
const updates: {flags?: bigint; has_verified_phone: boolean; suspicious_activity_flags?: number} = {
has_verified_phone: true,
};
const shouldClearSpammerFlag = (user.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
if (shouldClearSpammerFlag) {
updates.flags = user.flags & ~UserFlags.SPAMMER;
}
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~PHONE_ADD_CLEARABLE_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await users.patchUpsert(userId, updates, user.toRow());
await contactChangeLog.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: userId,
});
await gateway.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
if (shouldClearSpammerFlag) {
await dispatchForcedGuildMemberUpdates(ctx, updatedUser);
}
}
async function dispatchForcedGuildMemberUpdates(ctx: ApiContext, updatedUser: User): Promise<void> {
const {users, gateway} = ctx.services;
const userPartial = mapUserToPartialResponse(updatedUser);
const guildIds = await users.getUserGuildIds(updatedUser.id);
for (const guildId of guildIds) {
const guildMemberResult = await gateway.getGuildMember({
guildId,
userId: updatedUser.id,
});
if (!guildMemberResult.success || !guildMemberResult.memberData) {
continue;
}
await gateway.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: {
...guildMemberResult.memberData,
user: userPartial,
},
});
}
}
async function enforceSmsSendProtections(ctx: ApiContext, phone: string, user: User | null): Promise<void> {
if (user) {
await checkPhoneSendLimit(
ctx,
{
identifier: `auth:phone:send:any:${user.id.toString()}`,
...ACCOUNT_SMS_SEND_LIMIT,
},
'user',
);
}
await checkPhoneSendLimit(
ctx,
assertPhoneFormat(phone);
const user = await ctx.services.users.findUnique(userId);
if (!user || (user.flags & UserFlags.DELETED) !== 0n) throw new PhoneVerificationRequiredError();
if (user.isBot) throw new BotUserAuthEndpointAccessDeniedError();
const reply = await getPhoneVerificationClient().check(
{
identifier: `auth:phone:send:phone:${phone}`,
...PHONE_SMS_SEND_LIMIT,
user_id: user.id.toString(),
user_flags: user.flags.toString(),
phone,
code,
client_ip: ctx.request.clientIp ?? '',
},
'shared',
requestInfo(ctx, userId, phone),
);
}
async function checkPhoneSendLimit(
ctx: ApiContext,
config: {identifier: string; maxAttempts: number; windowMs: number},
scope: RateLimitScope,
): Promise<void> {
const result = await ctx.services.rateLimit.checkLimit(config);
if (!result.allowed) {
throw createPhoneRateLimitError(result, scope);
}
}
function createPhoneRateLimitError(
result: Pick<RateLimitResult, 'retryAfter' | 'retryAfterDecimal' | 'limit' | 'resetTime' | 'resetAfterDecimal'>,
scope: RateLimitScope,
): RateLimitError {
return new RateLimitError({
code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED,
retryAfter: result.retryAfter,
retryAfterDecimal: result.retryAfterDecimal,
limit: result.limit,
resetTime: result.resetTime,
resetAfterDecimal: result.resetAfterDecimal,
scope,
});
}
async function throwIfProviderCooldownActive(ctx: ApiContext, phone: string, userId: UserID | null): Promise<void> {
const accountCooldown = userId ? await readCooldown(ctx, getProviderAccountCooldownKey(userId)) : null;
const phoneCooldown = await readCooldown(ctx, getProviderPhoneCooldownKey(phone));
const cooldown = pickLongerCooldown(accountCooldown, phoneCooldown);
if (!cooldown) {
return;
}
throw createPhoneRateLimitError(
{
retryAfter: cooldown.retryAfter,
retryAfterDecimal: cooldown.retryAfter,
limit: 1,
resetTime: new Date(Date.now() + cooldown.retryAfter * 1000),
resetAfterDecimal: cooldown.retryAfter,
},
cooldown.scope,
);
}
async function recordProviderCooldown(
ctx: ApiContext,
phone: string,
userId: UserID | null,
error: TwilioVerificationRateLimitError,
): Promise<void> {
const {cache} = ctx.services;
const ttlSeconds = Math.max(1, Math.ceil(error.cooldownMs / 1000));
const payload = {provider_message: error.message};
if ((error.cooldownScope === 'account' || error.cooldownScope === 'account_and_phone') && userId) {
await cache.set(getProviderAccountCooldownKey(userId), payload, ttlSeconds);
}
if (error.cooldownScope === 'phone' || error.cooldownScope === 'account_and_phone') {
await cache.set(getProviderPhoneCooldownKey(phone), payload, ttlSeconds);
}
}
function getProviderAccountCooldownKey(userId: UserID): string {
return `auth:phone:provider-cooldown:user:${userId.toString()}`;
}
function getProviderPhoneCooldownKey(phone: string): string {
return `auth:phone:provider-cooldown:phone:${phone}`;
}
async function readCooldown(ctx: ApiContext, key: string): Promise<{retryAfter: number; scope: RateLimitScope} | null> {
const {cache} = ctx.services;
const payload = await cache.get<unknown>(key);
if (!payload) {
return null;
}
const ttl = await cache.ttl(key);
const retryAfter = ttl > 0 ? ttl : 60;
return {
retryAfter,
scope: key.includes(':phone:') ? 'shared' : 'user',
};
}
function pickLongerCooldown(
a: {retryAfter: number; scope: RateLimitScope} | null,
b: {retryAfter: number; scope: RateLimitScope} | null,
): {retryAfter: number; scope: RateLimitScope} | null {
if (!a) return b;
if (!b) return a;
return a.retryAfter >= b.retryAfter ? a : b;
}
type PhoneVerdictResult =
| {verdict: 'accept'; rejectReason: null}
| {verdict: 'reject'; rejectReason: PhoneAttemptRejectReason}
| {verdict: 'require_inbound'; inboundReason: PhoneAttemptInboundReason};
export function errorForPhoneRejectReason(reason: PhoneAttemptRejectReason): FluxerError {
switch (reason) {
case 'invalid_format':
return new InvalidPhoneNumberError();
case 'banned_prefix':
return new PhoneCountryNotSupportedError();
case 'lookup_unavailable':
return new PhoneLookupUnavailableError();
case 'invalid_number':
return new PhoneNumberNotInServiceError();
case 'line_type_not_mobile':
case 'line_type_hard_rejected':
return new PhoneNumberNotMobileError();
case 'sms_pumping_risk_high':
case 'behavioural_risk_blocked':
return new PhoneVerificationNeedsReviewError();
}
}
function computePhoneVerdict(lookup: PhoneLookupResult | null, phone: string): PhoneVerdictResult {
if (lookup == null) {
return {verdict: 'reject', rejectReason: 'lookup_unavailable'};
}
if (!lookup.valid) {
return {verdict: 'reject', rejectReason: 'invalid_number'};
}
if (lookup.lineType && VOIP_PHONE_LINE_TYPES.has(lookup.lineType)) {
return {verdict: 'require_inbound', inboundReason: 'voip'};
}
if (isCanadianPhoneNumber(phone)) {
return {verdict: 'require_inbound', inboundReason: 'canadian'};
}
if (!lookup.lineType || lookup.lineType === 'unknown') {
return {verdict: 'require_inbound', inboundReason: 'unknown_line_type'};
}
if (HARD_REJECT_PHONE_LINE_TYPES.has(lookup.lineType)) {
return {verdict: 'reject', rejectReason: 'line_type_hard_rejected'};
}
if (!ACCEPTED_PHONE_LINE_TYPES.has(lookup.lineType)) {
return {verdict: 'reject', rejectReason: 'line_type_not_mobile'};
}
const threshold = getSmsPumpingRiskThreshold(lookup.countryCode);
if (lookup.smsPumpingRiskScore !== null && lookup.smsPumpingRiskScore >= threshold) {
return {verdict: 'reject', rejectReason: 'sms_pumping_risk_high'};
}
return {verdict: 'accept', rejectReason: null};
}
const CANADIAN_NPAS: ReadonlySet<string> = new Set([
'204',
'226',
'236',
'249',
'250',
'263',
'289',
'306',
'343',
'354',
'365',
'367',
'368',
'382',
'387',
'403',
'416',
'418',
'428',
'431',
'437',
'438',
'450',
'468',
'474',
'506',
'514',
'519',
'548',
'579',
'581',
'584',
'587',
'604',
'613',
'639',
'647',
'672',
'683',
'705',
'709',
'742',
'753',
'778',
'780',
'782',
'807',
'819',
'825',
'867',
'873',
'879',
'902',
'905',
'942',
]);
function isCanadianPhoneNumber(e164: string): boolean {
if (!e164.startsWith('+1') || e164.length < 5) return false;
const npa = e164.slice(2, 5);
return CANADIAN_NPAS.has(npa);
if (reply.result === 'error') throw errorForPhoneReply(reply);
const action = reply.action;
if (action.type !== 'phone_verified' || action.user_id !== user.id.toString()) {
Logger.error({actionType: action.type}, 'Phone verification returned an unexpected action');
throw new SmsVerificationUnavailableError();
}
if (action.expires_at_ms <= Date.now()) {
const expired = outcomeOf(action, 'expired');
await emitActivity('action_outcome', action.key, expired, null, expired.action_id);
throw new SmsVerificationUnavailableError();
}
const outcome = await applyPhoneVerified(accountStateDepsFromContext(ctx, getAdminRepository()), action);
await emitActivity('action_outcome', action.key, outcome, null, outcome.action_id);
if (outcome.status === 'ineligible') throw new PhoneVerificationRequiredError();
}
+33 -169
View File
@@ -4,10 +4,12 @@ import type {ApiContext} from '@app/api/ApiContext';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {isBlockedEmailDomain} from '@app/api/infrastructure/activity/SharedLists';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
@@ -23,17 +25,6 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
normalizePolicyContactDomain,
} from '@app/api/risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
import {deferPhoneFlagsUntilCommunityJoin} from '@app/api/risk/DeferredPhoneGate';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {IRiskAssessmentRepository} from '@app/api/risk/RiskAssessmentRepository';
import {deriveLatestRiskContext} from '@app/api/risk/RiskHistoryContext';
import * as AgeUtils from '@app/api/utils/AgeUtils';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {lookupGeoip} from '@app/api/utils/IpUtils';
@@ -41,6 +32,7 @@ import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
@@ -54,8 +46,6 @@ import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
const DEFAULT_MINIMUM_AGE = 13;
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
if (!match) {
@@ -83,13 +73,6 @@ export interface RegistrationDependencies {
singleCommunityService: SingleCommunityService;
discriminatorService: IDiscriminatorService;
kvActivityTracker: KVActivityTracker;
registrationRiskEvaluator: IRegistrationRiskEvaluator;
accountPolicyEvaluator: IAccountPolicyEvaluator;
isEmailDomainSuspicious: (domain: string) => Promise<boolean>;
isEmailDomainDisposable: (domain: string) => Promise<boolean>;
registrationEventsRepository: IRegistrationEventsRepository;
riskAssessmentRepository: IRiskAssessmentRepository;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'upsertLatestContext' | 'recordOutcomeForUser'>;
}
interface RegistrationTokenResult {
@@ -114,20 +97,8 @@ export async function register(
{data, request, requestCache}: RegisterParams,
): Promise<RegisterResult> {
const {users, snowflake, emailDnsValidation, config} = ctx.services;
const {
inviteService,
instanceConfigRepository,
singleCommunityService,
discriminatorService,
kvActivityTracker,
registrationRiskEvaluator,
accountPolicyEvaluator,
isEmailDomainSuspicious,
isEmailDomainDisposable,
registrationEventsRepository,
riskAssessmentRepository,
riskHistoryRepository,
} = deps;
const {inviteService, instanceConfigRepository, singleCommunityService, discriminatorService, kvActivityTracker} =
deps;
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
@@ -152,7 +123,7 @@ export async function register(
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
const minAge = accountPolicyEvaluator.getMinimumAgeForRegion(countryCode, DEFAULT_MINIMUM_AGE);
const minAge = getRegionalMinimumAge(countryCode);
if (!AuthUtility.validateAge(ctx, {dateOfBirth: dateOfBirthInput, minAge})) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.MUST_BE_MINIMUM_AGE, {minAge});
}
@@ -163,28 +134,17 @@ export async function register(
}
const rawEmail = data.email ?? null;
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const userAgent = request.headers.get('user-agent');
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
let contactDomain: string | null = null;
let contactDomainAdminListed = false;
let contactDomainDisposable = false;
let contactDomainBlocked = false;
let contactDomainStepUpRequired = false;
if (rawEmail) {
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
if (isBlockedEmailDomain(extractEmailDomain(rawEmail))) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
await assertEmailNotBlocklisted(rawEmail, 'email');
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
if (contactDomainBlocked) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
[contactDomainAdminListed, contactDomainDisposable] = contactDomain
? await Promise.all([isEmailDomainSuspicious(contactDomain), isEmailDomainDisposable(contactDomain)])
: [false, false];
contactDomainStepUpRequired = contactDomainBlocked || contactDomainAdminListed || contactDomainDisposable;
const emailTaken = await users.findByEmail(rawEmail);
if (emailTaken) throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
@@ -330,108 +290,25 @@ export async function register(
void kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
Logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
});
const isUnclaimed = !rawEmail;
const usernameIsUserChosen = data.username != null || data.global_name != null;
const riskResult = await registrationRiskEvaluator.evaluate({
email: rawEmail,
clientIp,
locale: userLocale,
timezone: null,
userAgent,
username,
globalName: data.global_name ?? null,
usernameIsUserChosen,
isUnclaimed,
});
const policyDecision = accountPolicyEvaluator.evaluate({
contact: {
value: rawEmail,
domain: contactDomain,
domainAdminListed: contactDomainAdminListed,
domainDisposable: contactDomainDisposable,
domainBlocked: contactDomainBlocked,
domainStepUpRequired: contactDomainStepUpRequired,
await emitActivity(
'registration',
user.id.toString(),
{
user_id: user.id.toString(),
method: data.password ? 'password' : rawEmail ? 'other' : 'unclaimed',
email: rawEmail,
username,
username_user_chosen: data.username != null || data.global_name != null,
global_name: data.global_name ?? null,
locale: userLocale,
timezone: null,
invite_code: data.invite_code?.trim() || null,
suspicious_flags: user.suspiciousActivityFlags ?? 0,
flags: user.flags.toString(),
},
region: {
code: countryCode,
stepUpRequired: countryRequiresInboundPhoneVerification(countryCode),
},
assessment: {
raw: riskResult.assessment,
level: riskResult.level,
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
null,
user.id.toString(),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
email: rawEmail ?? null,
clientIp,
asn: riskResult.assessment.signals.geoIpAsn?.asn ?? null,
updatedAt: createdAt,
});
if (combinedFlags !== 0) {
user = await users.patchUpsert(user.id, {suspicious_activity_flags: combinedFlags}, user.toRow());
}
registrationEventsRepository
.recordEvent({
userId: userId.toString(),
email: rawEmail ?? null,
emailDomain: riskContext.emailDomain,
ip: clientIp,
locale: userLocale,
createdAt,
})
.catch((error) => {
Logger.warn(
{userId: userId.toString(), error},
'[AuthRegistration] Failed to record registration velocity event',
);
});
(async () => {
try {
await riskHistoryRepository.upsertLatestContext(riskContext);
if (policyDecision.riskHistoryOutcomeCodes.length > 0) {
await riskHistoryRepository.recordOutcomeForUser({
userId: userId.toString(),
occurredAt: createdAt,
source: 'registration_risk',
outcomeCodes: [...policyDecision.riskHistoryOutcomeCodes],
});
}
} catch (error) {
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist direct risk history');
}
})();
riskAssessmentRepository
.recordAssessment({
userId,
ip: clientIp,
email: rawEmail ?? null,
locale: userLocale,
assessment: riskResult.assessment,
})
.catch((error) => {
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist risk assessment');
});
for (const event of policyDecision.auditEvents) {
if (isAssessmentThresholdAuditEvent(event)) {
Logger.warn(
{
userId: userId.toString(),
email: rawEmail,
ip: clientIp,
score: riskResult.assessment.riskScore,
reasoning: riskResult.assessment.reasoning,
policyRuleId: event.ruleId,
},
'[AuthRegistration] Account policy emitted assessment threshold notice',
);
}
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.pendingApproval) {
@@ -440,24 +317,11 @@ export async function register(
user_id: user.id.toString(),
};
}
if (policyDecision.inviteAutoJoinEnabled) {
await maybeAutoJoinInvite(inviteService, {
userId,
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
requestCache,
});
} else {
Logger.info(
{
userId: userId.toString(),
riskLevel: riskResult.level,
riskScore: riskResult.assessment.riskScore,
inviteCode: data.invite_code,
reason: policyDecision.inviteAutoJoinSkipReason,
},
'[AuthRegistration] Skipping invite auto-join because account policy disabled it',
);
}
await maybeAutoJoinInvite(inviteService, {
userId,
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
requestCache,
});
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
+2
View File
@@ -10,6 +10,7 @@ import {
REGISTRATION_REJECTED_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVAccountDeletionQueue} from '@app/api/middleware/ServiceSingletons';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {lookupGeoip} from '@app/api/utils/IpUtils';
@@ -87,6 +88,7 @@ export async function createAuthSession(
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
user = await AuthUtility.reactivateOnSignIn(ctx, user, getKVAccountDeletionQueue());
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null;

Some files were not shown because too many files have changed in this diff Show More