mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2636e9cc13 | ||
|
|
944b586f22 | ||
|
|
4f968bbc47 | ||
|
|
d433a039b5 | ||
|
|
b30ea361d3 | ||
|
|
9908518f5b | ||
|
|
364084c819 | ||
|
|
c488906131 | ||
|
|
39c72f0fb0 |
@@ -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:
|
||||
|
||||
Vendored
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
admin_flags, api::client::AdminApiClient, middleware::flash::FlashData,
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
middleware::flash::FlashData,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
@@ -303,11 +305,53 @@ pub async fn dispatch(
|
||||
"Failed to schedule user deletion",
|
||||
)
|
||||
}
|
||||
"cancel_deletion" => DispatchOutcome::from_result(
|
||||
client.cancel_deletion(user_id).await,
|
||||
"User deletion cancelled successfully",
|
||||
"Failed to cancel user deletion",
|
||||
),
|
||||
"cancel_deletion" => {
|
||||
let Some(expected) = get("expected_pending_deletion_at") else {
|
||||
return DispatchOutcome::error(
|
||||
"The pending deletion is missing from the form. Reload and review.",
|
||||
);
|
||||
};
|
||||
if !form.bool_value("confirm") {
|
||||
return DispatchOutcome::error(
|
||||
"Confirm whose deletion you are cancelling before submitting",
|
||||
);
|
||||
}
|
||||
let Some(private_reason) = get("private_reason") else {
|
||||
return DispatchOutcome::error("A private reason is required to cancel a deletion");
|
||||
};
|
||||
let notify_user = form.bool_value("notify_user");
|
||||
match client
|
||||
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The pending deletion changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
|
||||
DispatchOutcome::error("Failed to cancel user deletion")
|
||||
}
|
||||
}
|
||||
}
|
||||
"annotate_ban" => {
|
||||
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
|
||||
return DispatchOutcome::error("The ban audit log entry is missing from the form");
|
||||
};
|
||||
let Some(note) = get("note") else {
|
||||
return DispatchOutcome::error("Note is required");
|
||||
};
|
||||
match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await {
|
||||
Ok(()) => DispatchOutcome::success("Note added to the ban"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The ban changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
|
||||
DispatchOutcome::error("Failed to add the note to the ban")
|
||||
}
|
||||
}
|
||||
}
|
||||
"change_dob" => {
|
||||
let Some(dob) = get("date_of_birth") else {
|
||||
return DispatchOutcome::error("Date of birth is required");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(¬e.created_at)) "): "
|
||||
(note.audit_log_reason.as_deref().unwrap_or(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id);
|
||||
div class="space-y-3" {
|
||||
(form_label("Add a note to this ban"))
|
||||
textarea name="note" rows="2" required maxlength="512"
|
||||
placeholder="Appended to the ban. The original reason is kept."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {}
|
||||
(form_actions(html! {
|
||||
(submit_button("Add Note"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_deletion_summary(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
scheduler: Option<&AdminUser>,
|
||||
) -> (String, Markup) {
|
||||
let scheduler_name = match (user.deletion_scheduled_by.as_deref(), scheduler) {
|
||||
(None, _) => "an unrecorded source".to_owned(),
|
||||
(Some(id), _) if id == user.id => "the user".to_owned(),
|
||||
(Some(_), Some(scheduler)) => scheduler.username.clone(),
|
||||
(Some(id), None) => id.to_owned(),
|
||||
};
|
||||
let reason = user
|
||||
.deletion_reason_code
|
||||
.map_or_else(|| "no reason code".to_owned(), deletion_reason_label);
|
||||
let due = user
|
||||
.pending_deletion_at
|
||||
.as_deref()
|
||||
.map(format_admin_timestamp)
|
||||
.unwrap_or_default();
|
||||
let markup = html! {
|
||||
dl class="mb-4 space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Scheduled by: " }
|
||||
dd class="inline" {
|
||||
@match user.deletion_scheduled_by.as_deref() {
|
||||
Some(id) => {
|
||||
a href={(base) "/users/" (id)} class="underline" { (scheduler_name) }
|
||||
}
|
||||
None => { (scheduler_name) }
|
||||
}
|
||||
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
|
||||
" on " (format_admin_timestamp(at))
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Due: " }
|
||||
dd class="inline" { (due) }
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Reason: " }
|
||||
dd class="inline" { (reason) }
|
||||
}
|
||||
@if let Some(public_reason) = &user.deletion_public_reason {
|
||||
div {
|
||||
dt class="inline font-medium" { "Public reason: " }
|
||||
dd class="inline" { (public_reason) }
|
||||
}
|
||||
}
|
||||
@if let Some(private_reason) = &user.deletion_audit_log_reason {
|
||||
div {
|
||||
dt class="inline font-medium" { "Private reason: " }
|
||||
dd class="inline" { (private_reason) }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
format!("Cancel {scheduler_name}'s deletion ({reason}, due {due})"),
|
||||
markup,
|
||||
)
|
||||
}
|
||||
|
||||
fn deletion_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
scheduler: Option<&AdminUser>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Account Deletion", html! {
|
||||
@if user.pending_deletion_at.is_some() {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
@let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
(summary)
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Cancel Deletion"))
|
||||
}))
|
||||
input type="hidden" name="expected_pending_deletion_at" value=(pending);
|
||||
div class="space-y-3" {
|
||||
(form_label("Private Reason"))
|
||||
input type="text" name="private_reason" required
|
||||
placeholder="Why this deletion is being cancelled (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Deletion"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
@@ -153,11 +353,12 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Reason"))
|
||||
select name="reason_code"
|
||||
select name="reason_code" required
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
@@ -513,3 +714,112 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn user(extra: Value) -> AdminUser {
|
||||
let mut value =
|
||||
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
|
||||
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
|
||||
target.extend(fields.clone());
|
||||
}
|
||||
serde_json::from_value(value).expect("valid admin user")
|
||||
}
|
||||
|
||||
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1400000000000000001",
|
||||
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
|
||||
"action": action,
|
||||
"target_id": "1500000000000000001",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": reason,
|
||||
"metadata": metadata,
|
||||
"created_at": "2026-09-01T10:00:00.000Z"
|
||||
}))
|
||||
.expect("valid audit log entry")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
|
||||
let target = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3,
|
||||
"deletion_public_reason": "Spam",
|
||||
"deletion_audit_log_reason": "Report batch 12",
|
||||
"deletion_scheduled_by": "1400000000000000001",
|
||||
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
|
||||
}));
|
||||
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
|
||||
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
|
||||
assert!(markup.contains("lilith"));
|
||||
assert!(markup.contains("Report batch 12"));
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
|
||||
)
|
||||
);
|
||||
assert!(markup.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
|
||||
assert!(markup.contains(r#"name="private_reason" required"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_makes_the_reason_an_explicit_choice() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
|
||||
assert!(markup.contains(r#"<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&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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user