Compare commits

...
Author SHA1 Message Date
HampusandGitHub f9108f24ce feat(self-host): add an overlay that turns off bundled seaweedfs (#3041) 2026-09-29 19:49:02 +02:00
HampusandGitHub e98b77a54a fix(api): stop treating users without a birth date as minors (#3040) 2026-09-29 18:27:15 +02:00
HampusandGitHub 2636e9cc13 fix(voice): lower DeepFilterNet attenuation limit to 30 dB (#3039) 2026-09-29 18:16:13 +02:00
JiraliteandGitHub 944b586f22 fix(UseForwardDestinations): hide system user (#3038) 2026-09-29 18:14:19 +02:00
HampusandGitHub 4f968bbc47 feat(captcha): make ALTCHA the only captcha (#3035) 2026-09-29 17:00:15 +02:00
HampusandGitHub d433a039b5 feat(profile): ship profile timezone to everyone (#3034) 2026-09-29 16:28:40 +02:00
HampusandGitHub b30ea361d3 fix(push): stop pushes for read, silent and muted messages (#3033) 2026-09-29 15:58:46 +02:00
HampusandGitHub 9908518f5b feat(app): add quick reply and edit keybinds (#3032) 2026-09-29 15:50:56 +02:00
HampusandGitHub 364084c819 refactor(api): emit moderation events and apply account actions (#3031) 2026-09-29 12:24:15 +02:00
HampusandGitHub c488906131 feat(voice): ship noise suppression treatment to everyone (#3029) 2026-09-29 03:43:58 +02:00
HampusandGitHub 39c72f0fb0 fix(desktop): require readable keyboards for Linux input access (#3026) 2026-09-28 22:27:20 +02:00
HampusandGitHub 3736d94d73 feat(premium): let self-hosted instances sell premium and gifts (#3025) 2026-09-28 21:21:51 +02:00
HampusandGitHub 192cec689a fix(app): keep voice connections of one session across channels (#3023) 2026-09-28 19:50:47 +02:00
HampusandGitHub e895c41bf0 fix(app): tighten the composer status row (#3022) 2026-09-28 19:50:00 +02:00
HampusandGitHub 997d98c65c fix(app): fade messages behind the composer status row (#3020) 2026-09-28 18:47:42 +02:00
HampusandGitHub c9ae5b6ee8 fix(app): smooth the fluxer.com migration and expired re-login (#3019) 2026-09-28 18:11:18 +02:00
HampusandGitHub a728be4062 fix(app): respect time format setting in profile local time (#3018) 2026-09-28 17:48:55 +02:00
HampusandGitHub fce81367fb fix(app): stop message text showing through the slowmode hint (#3017) 2026-09-28 17:29:15 +02:00
HampusandGitHub 5a4edc0b59 fix(api): make read state clear endpoint a no-op (#3015) 2026-09-28 16:31:30 +02:00
HampusandGitHub 713ae5f7f5 feat(api): restrict dms to friends by default for new users (#3013) 2026-09-28 15:02:20 +02:00
HampusandGitHub eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
HampusandGitHub 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
HampusandGitHub dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
HampusandGitHub ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
HampusandGitHub 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
HampusandGitHub d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
HampusandGitHub 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
HampusandGitHub dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
HampusandGitHub f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
HampusandGitHub 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
HampusandGitHub 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
HampusandGitHub 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
HampusandGitHub 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
HampusandGitHub 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
HampusandGitHub f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
HampusandGitHub f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
HampusandGitHub 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
HampusandGitHub ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
HampusandGitHub 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
HampusandGitHub 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
990 changed files with 84471 additions and 74404 deletions
-6
View File
@@ -202,11 +202,6 @@ services:
target: /workspaces/fluxer/fluxer_api/pkgs/rate_limit/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-sms-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/sms/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-virus-scan-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/virus_scan/node_modules
@@ -384,7 +379,6 @@ volumes:
fluxer-api-mime-utils-node-modules:
fluxer-api-nats-node-modules:
fluxer-api-rate-limit-node-modules:
fluxer-api-sms-node-modules:
fluxer-api-virus-scan-node-modules:
fluxer-api-worker-node-modules:
fluxer-app-list-utils-node-modules:
+3
View File
@@ -336,6 +336,9 @@ jobs:
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
"$FLUXER_CI_BIN" ci --step gateway_fmt
+4 -3
View File
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
@@ -168,6 +168,7 @@ endorsement rights.
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
-3
View File
@@ -107,9 +107,6 @@ FLUXER_EMAIL_SMTP_PORT=1025
FLUXER_EMAIL_SMTP_USERNAME=dev
FLUXER_EMAIL_SMTP_PASSWORD=dev
FLUXER_EMAIL_SMTP_SECURE=false
FLUXER_SMS_ENABLED=false
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_SEARCH_ENGINE=meilisearch
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
+6 -10
View File
@@ -84,6 +84,11 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
# With the object store outside the stack, add this overlay to COMPOSE_FILE and
# the bundled seaweedfs no longer starts. Put it after any other overlay, such as
# docker-compose.yml:docker-compose.proxy.yml:external-object-store.compose.yml.
# Needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:external-object-store.compose.yml
# The other bundled services, pointed elsewhere. Removing a service from the
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
@@ -98,15 +103,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 +226,6 @@ FLUXER_EMAIL_SMTP_USERNAME=
FLUXER_EMAIL_SMTP_PASSWORD=
FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. These are ceilings, not allocations, and the defaults suit a
-8
View File
@@ -24,7 +24,6 @@ x-fluxer-env: &fluxer-env
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
@@ -74,13 +73,6 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
@@ -0,0 +1,14 @@
services:
seaweedfs:
profiles: [bundled-object-store]
seaweedfs-init:
profiles: [bundled-object-store]
api:
depends_on:
seaweedfs-init: !reset null
worker:
depends_on:
seaweedfs-init: !reset null
media-proxy:
depends_on:
seaweedfs-init: !reset null
+330 -219
View File
@@ -2743,7 +2743,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.",
"description": "Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -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,8 +10627,9 @@
"additionalProperties": false
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10636,7 +10741,9 @@
"favicon_url": {"nullable": true, "type": "string"},
"theme_color": {"nullable": true, "type": "string"},
"status_page_url": {"nullable": true, "type": "string"},
"status_page_incident_history_url": {"nullable": true, "type": "string"}
"status_page_incident_history_url": {"nullable": true, "type": "string"},
"premium_product_name": {"type": "string"},
"premium_info_url": {"nullable": true, "type": "string"}
},
"required": [
"product_name",
@@ -10647,7 +10754,9 @@
"favicon_url",
"theme_color",
"status_page_url",
"status_page_incident_history_url"
"status_page_incident_history_url",
"premium_product_name",
"premium_info_url"
],
"additionalProperties": false
},
@@ -10713,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": [
@@ -10733,8 +10832,7 @@
"premium_mode",
"services",
"services_resolved",
"services_available",
"deferred_phone_gate"
"services_available"
],
"additionalProperties": false
},
@@ -10753,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": {
@@ -10840,7 +10913,7 @@
"additionalProperties": false
}
},
"required": ["gif", "youtube", "captcha", "email", "bluesky"],
"required": ["gif", "youtube", "email", "bluesky"],
"additionalProperties": false
},
"media": {
@@ -10946,20 +11019,23 @@
},
"required": ["attachment_decay"],
"additionalProperties": false
}
},
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
},
"required": [
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"push_relay",
"domain_migration",
"captcha",
"experiment_delivery",
"registration",
"self_hosted",
"app_public",
"policy",
"integrations",
"media"
"media",
"billing"
],
"additionalProperties": false
},
@@ -11083,14 +11159,12 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
},
"voice_noise_suppression": {
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -11138,7 +11212,9 @@
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
"premium_info_url": {"nullable": true, "type": "string", "maxLength": 2048}
}
},
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
@@ -11171,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",
@@ -11295,18 +11357,10 @@
"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}
}
}
}
}
},
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
}
},
"ListGuildStickersResponse": {
@@ -12305,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": {
@@ -12358,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"},
@@ -12735,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"}
}
@@ -12753,7 +12796,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12783,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"
@@ -12804,7 +12847,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12817,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"
@@ -12846,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",
@@ -13161,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": {
@@ -13416,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"
}
]
},
@@ -15165,7 +15192,28 @@
{"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"}
]
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceBillingUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"stripe_secret_key": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"stripe_webhook_secret": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetUpdateRequest"}
},
"country_currencies": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/BillingCountryCurrenciesSchema"}]
},
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
}
},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
"x-enumNames": ["open", "approval", "closed"],
@@ -15184,7 +15232,15 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"CaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 20000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
@@ -15195,51 +15251,22 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"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": {
"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}
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"GatewayRolloutConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15254,9 +15281,84 @@
"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": {
"maxItems": 32,
"type": "array",
"items": {"$ref": "#/components/schemas/StripePriceIdSchema"}
}
},
"BillingCountryCurrenciesSchema": {
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/PremiumCurrency"}
},
"BillingPriceSetUpdateRequest": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
}
},
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
"InstanceBillingResponse": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"effective_enabled": {"type": "boolean"},
"stripe_secret_key_set": {"type": "boolean"},
"stripe_webhook_secret_set": {"type": "boolean"},
"stripe_secret_key_stored": {"type": "boolean"},
"stripe_webhook_secret_stored": {"type": "boolean"},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"effective_automatic_tax": {"type": "boolean"},
"effective_tax_id_collection": {"type": "boolean"},
"effective_terms_consent_required": {"type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetResponse"}
},
"country_currencies": {"nullable": true, "type": "object", "additionalProperties": {"type": "string"}},
"legacy_prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"type": "array", "items": {"type": "string"}}
},
"billing_active": {"type": "boolean"},
"stripe_serviceable": {"type": "boolean"},
"catalog_mode": {"$ref": "#/components/schemas/BillingCatalogModeSchema"},
"webhook_url": {"type": "string"}
},
"required": [
"enabled",
"effective_enabled",
"stripe_secret_key_set",
"stripe_webhook_secret_set",
"stripe_secret_key_stored",
"stripe_webhook_secret_stored",
"automatic_tax",
"tax_id_collection",
"terms_consent_required",
"effective_automatic_tax",
"effective_tax_id_collection",
"effective_terms_consent_required",
"default_currency",
"prices",
"country_currencies",
"legacy_prices",
"billing_active",
"stripe_serviceable",
"catalog_mode",
"webhook_url"
],
"additionalProperties": false
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
@@ -15267,14 +15369,24 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"CaptchaConfigResponse": {
"type": "object",
"properties": {
"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", "cost", "max_counter"],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"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": "push-service-delivery-v1",
"default": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
@@ -15286,12 +15398,21 @@
"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_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
@@ -15299,69 +15420,28 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"PushRelayConfigResponse": {
"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"}]
"relay_consent_accepted": {"default": false, "type": "boolean"},
"relay_consent_accepted_at": {
"default": null,
"nullable": true,
"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))$"
},
"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}$"}
},
"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}
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": [
"enabled",
"config_version",
"default_backend",
"enabled_backends",
"allow_user_override",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
],
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"GatewayRolloutConfigResponse": {
@@ -15394,6 +15474,18 @@
],
"additionalProperties": false
},
"BillingCatalogModeSchema": {"type": "string", "enum": ["env", "operator"]},
"BillingPriceSetResponse": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
},
"required": ["monthly", "yearly", "gift_1_month", "gift_1_year"],
"additionalProperties": false
},
"JobLedgerEntrySchema": {
"type": "object",
"properties": {
@@ -15490,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"}},
@@ -15538,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",
@@ -15645,9 +15755,10 @@
"id": {"type": "string", "description": "The credential ID"},
"name": {"type": "string", "description": "User-assigned name for the credential"},
"created_at": {"type": "string", "description": "When the credential was registered"},
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"}
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"},
"rp_id": {"type": "string", "description": "Relying party ID the passkey belongs to"}
},
"required": ["id", "name", "created_at", "last_used_at"],
"required": ["id", "name", "created_at", "last_used_at", "rp_id"],
"additionalProperties": false
},
"VoiceServerAdminResponse": {
-6
View File
@@ -17,9 +17,6 @@ pub const JOBS_CANCEL: &str = "jobs:cancel";
pub const BAN_EMAIL_ADD: &str = "ban:email:add";
pub const BAN_EMAIL_CHECK: &str = "ban:email:check";
pub const BAN_EMAIL_REMOVE: &str = "ban:email:remove";
pub const SUSPICIOUS_EMAIL_DOMAIN_ADD: &str = "suspicious_email_domain:add";
pub const SUSPICIOUS_EMAIL_DOMAIN_CHECK: &str = "suspicious_email_domain:check";
pub const SUSPICIOUS_EMAIL_DOMAIN_REMOVE: &str = "suspicious_email_domain:remove";
pub const BAN_PHRASE_ADD: &str = "ban:phrase:add";
pub const BAN_PHRASE_CHECK: &str = "ban:phrase:check";
pub const BAN_PHRASE_REMOVE: &str = "ban:phrase:remove";
@@ -129,9 +126,6 @@ pub const ALL_ACLS: &[&str] = &[
BAN_EMAIL_ADD,
BAN_EMAIL_CHECK,
BAN_EMAIL_REMOVE,
SUSPICIOUS_EMAIL_DOMAIN_ADD,
SUSPICIOUS_EMAIL_DOMAIN_CHECK,
SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
BAN_PHRASE_ADD,
BAN_PHRASE_CHECK,
BAN_PHRASE_REMOVE,
+30 -79
View File
@@ -9,12 +9,11 @@ impl AdminApiClient {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_1: Some(generated_types::BanEmailRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -32,10 +31,9 @@ impl AdminApiClient {
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanIpRequest { ip: ip.to_owned() },
),
audit_log_reason,
)
.await
@@ -50,45 +48,14 @@ impl AdminApiClient {
self.check_blocklist_entry("ip", ip, None).await
}
pub async fn add_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn remove_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
.await
}
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
}
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_3: Some(generated_types::BanPhraseRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -110,16 +77,15 @@ impl AdminApiClient {
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_4: Some(generated_types::BanUrlRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -142,17 +108,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"url-domain",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_5: Some(generated_types::BanUrlDomainRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains,
notes: None,
severity: None,
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -178,17 +143,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"file-sha",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -231,17 +195,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"avatar-hash",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_7: Some(generated_types::BanAvatarHashRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -279,10 +242,9 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
generated_types::AdminBlocklistEntryCreateRequest::from(profile_substring_request(
scope, substring,
)?),
audit_log_reason,
)
.await
@@ -359,8 +321,6 @@ impl AdminApiClient {
}
}
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
@@ -380,15 +340,6 @@ fn blocklist_delete_scope(
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
Ok(generated_types::SuspiciousEmailDomainRequest {
domain: generated_types::SuspiciousEmailDomainRequestDomain::try_from(domain)
.map_err(|e| ApiError::Parse(e.to_string()))?,
})
}
fn profile_substring_request(
scope: &str,
substring: &str,
+17 -4
View File
@@ -90,10 +90,7 @@ impl AdminApiClient {
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
let mut headers = self.generated.inner().clone();
if let Some(reason) = audit_log_reason {
let mut value = HeaderValue::from_str(reason)
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
headers.insert("x-audit-log-reason", value);
headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?);
}
Ok(headers)
}
@@ -422,11 +419,27 @@ impl std::fmt::Display for ApiError {
}
}
fn audit_log_reason_header(reason: &str) -> ApiResult<HeaderValue> {
let mut value = HeaderValue::from_bytes(reason.as_bytes())
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn audit_log_reason_header_carries_utf8_bytes() {
let reason = "§ 3 Regel – wiederholt 日本";
let value = audit_log_reason_header(reason).expect("valid reason header");
assert_eq!(value.as_bytes(), reason.as_bytes());
assert!(value.is_sensitive());
assert!(audit_log_reason_header("line one\nline two").is_err());
}
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
+5
View File
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -11,6 +12,10 @@ impl AdminApiClient {
self.get("/admin/instance/config", None).await
}
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
self.get("/.well-known/fluxer", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
+6
View File
@@ -122,6 +122,12 @@ pub struct AdminUser {
pub pending_bulk_message_deletion_at: Option<String>,
pub deletion_reason_code: Option<i32>,
pub deletion_public_reason: Option<String>,
#[serde(default)]
pub deletion_audit_log_reason: Option<String>,
#[serde(default)]
pub deletion_scheduled_by: Option<String>,
#[serde(default)]
pub deletion_scheduled_at: Option<String>,
pub last_active_at: Option<String>,
pub last_active_ip: Option<String>,
pub last_active_ip_reverse: Option<String>,
@@ -0,0 +1,332 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::{InstanceConfigResponse, PremiumMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const BILLING_MAX_CURRENCIES: usize = 64;
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
pub const TRI_STATE_DEFAULT: &str = "default";
pub const TRI_STATE_ON: &str = "on";
pub const TRI_STATE_OFF: &str = "off";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingCatalogMode {
#[default]
Env,
Operator,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BillingPriceSet {
pub monthly: Option<String>,
pub yearly: Option<String>,
pub gift_1_month: Option<String>,
pub gift_1_year: Option<String>,
}
impl BillingPriceSet {
pub fn has_recurring_pair(&self) -> bool {
self.monthly.is_some() && self.yearly.is_some()
}
pub fn is_empty(&self) -> bool {
self.monthly.is_none()
&& self.yearly.is_none()
&& self.gift_1_month.is_none()
&& self.gift_1_year.is_none()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceBillingResponse {
pub enabled: Option<bool>,
#[serde(default)]
pub effective_enabled: bool,
#[serde(default)]
pub stripe_secret_key_set: bool,
#[serde(default)]
pub stripe_webhook_secret_set: bool,
#[serde(default)]
pub stripe_secret_key_stored: bool,
#[serde(default)]
pub stripe_webhook_secret_stored: bool,
pub default_currency: Option<String>,
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
pub country_currencies: Option<BTreeMap<String, String>>,
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
pub billing_active: bool,
#[serde(default)]
pub stripe_serviceable: bool,
#[serde(default)]
pub catalog_mode: BillingCatalogMode,
#[serde(default)]
pub webhook_url: String,
pub automatic_tax: Option<bool>,
pub tax_id_collection: Option<bool>,
pub terms_consent_required: Option<bool>,
#[serde(default)]
pub effective_automatic_tax: bool,
#[serde(default)]
pub effective_tax_id_collection: bool,
#[serde(default)]
pub effective_terms_consent_required: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceBillingUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_secret_key: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_webhook_secret: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_currency: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automatic_tax: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id_collection: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_consent_required: Option<Option<bool>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscovery {
#[serde(default)]
pub app_public: InstancePremiumDiscoveryAppPublic,
#[serde(default)]
pub features: InstancePremiumDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryAppPublic {
#[serde(default)]
pub branding: InstancePremiumDiscoveryBranding,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub premium_product_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryFeatures {
#[serde(default)]
pub premium_enabled: bool,
}
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn billing_response_round_trips_through_the_generated_contract() {
let value = json!({
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
"billing_active": false,
"stripe_serviceable": false,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"automatic_tax": null,
"tax_id_collection": false,
"terms_consent_required": true,
"effective_automatic_tax": false,
"effective_tax_id_collection": false,
"effective_terms_consent_required": true
});
let generated: generated_types::InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("generated billing response");
let ours: InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("hand-written billing response");
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
assert!(ours.stripe_secret_key_stored);
assert_eq!(ours.automatic_tax, None);
assert_eq!(ours.tax_id_collection, Some(false));
assert!(ours.effective_terms_consent_required);
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
assert_eq!(
serde_json::to_value(generated).expect("serializable generated"),
value
);
}
#[test]
fn default_billing_response_matches_the_generated_contract() {
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
.expect("generated billing response");
assert_eq!(value["catalog_mode"], json!("env"));
assert_eq!(value["prices"], json!(null));
}
#[test]
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
let mut prices = BTreeMap::new();
prices.insert(
"SEK".to_owned(),
BillingPriceSet {
monthly: Some("price_1Monthly".to_owned()),
yearly: Some("price_1Yearly".to_owned()),
..Default::default()
},
);
let update = InstanceBillingUpdateRequest {
enabled: Some(None),
stripe_secret_key: Some(None),
default_currency: Some(None),
prices: Some(Some(prices)),
country_currencies: Some(None),
legacy_prices: Some(Some(BTreeMap::new())),
automatic_tax: Some(None),
tax_id_collection: Some(Some(true)),
terms_consent_required: Some(Some(false)),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": {
"SEK": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": null,
"legacy_prices": {},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
})
);
assert_eq!(
serde_json::to_value(InstanceBillingUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn premium_discovery_reads_the_name_and_feature_flag() {
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
"features": {"premium_enabled": true, "stripe_enabled": false}
}))
.expect("discovery");
assert_eq!(discovery.premium_product_name(), Some("Gold"));
assert!(discovery.features.premium_enabled);
let empty: InstancePremiumDiscovery =
serde_json::from_value(json!({})).expect("empty discovery");
assert_eq!(empty.premium_product_name(), None);
assert!(!empty.features.premium_enabled);
assert_eq!(
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
}
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
}
);
assert!(
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
);
assert_eq!(
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
}
);
}
}
+111 -193
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
@@ -21,11 +21,15 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -45,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 {
@@ -80,7 +62,6 @@ impl Default for InstancePolicyResponse {
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -119,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,
@@ -141,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>,
@@ -330,6 +294,9 @@ pub struct AppBrandingConfigResponse {
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
#[serde(default = "default_premium_product_name")]
pub premium_product_name: String,
pub premium_info_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -344,6 +311,8 @@ impl Default for AppBrandingConfigResponse {
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
premium_product_name: default_premium_product_name(),
premium_info_url: None,
}
}
}
@@ -352,6 +321,10 @@ fn default_product_name() -> String {
"Fluxer".to_owned()
}
fn default_premium_product_name() -> String {
"Premium".to_owned()
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AppSetupConfigResponse {
#[serde(default)]
@@ -449,123 +422,58 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
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;
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)]
#[derive(Clone, Debug, Default, 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 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(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
}
}
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub struct PushRelayConfigUpdateRequest {
#[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 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 relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub struct DomainMigrationConfigResponse {
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_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for PushServiceDeliveryConfigResponse {
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -575,7 +483,43 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
#[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_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
pub enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for CaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: true,
cost: 5_000,
max_counter: 1_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct CaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub 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)]
@@ -692,11 +636,15 @@ 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>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -711,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)]
@@ -742,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>,
@@ -761,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")]
@@ -905,6 +825,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_product_name: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_info_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1010,40 +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 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 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_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
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
);
assert_eq!(
serde_json::to_value(generated_delivery)
@@ -1051,7 +970,8 @@ mod tests {
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1064,25 +984,23 @@ mod tests {
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
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>(
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod instance_billing;
mod instance_config;
mod jobs;
mod limit_config;
@@ -28,6 +29,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
pub use limit_config::*;
+36 -7
View File
@@ -439,6 +439,7 @@ impl AdminApiClient {
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
replace_pending_deletion_at: None,
};
let response = self
.generated_with_reason(audit_log_reason)?
@@ -449,16 +450,44 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let response = self
.generated()
.cancel_admin_user_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
pub async fn cancel_deletion(
&self,
user_id: &str,
expected_pending_deletion_at: &str,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = serde_json::json!({
"expected_pending_deletion_at": expected_pending_deletion_at,
"notify_user": notify_user,
});
let resp: UserMutationResponse = self
.delete_with_reason(
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
Some(&body),
audit_log_reason,
)
.await?;
Ok(resp.user)
}
pub async fn annotate_ban(
&self,
user_id: &str,
ban_audit_log_id: &str,
note: &str,
) -> ApiResult<()> {
let body = serde_json::json!({
"ban_audit_log_id": ban_audit_log_id,
"note": note,
});
self.post_void(
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
Some(&body),
)
.await
}
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),
-6
View File
@@ -23,10 +23,6 @@ pub fn router() -> Router<AppState> {
Router::new()
.route("/ip-bans", get(ip_bans).post(ip_bans_post))
.route("/email-bans", get(email_bans).post(email_bans_post))
.route(
"/suspicious-email-domains",
get(suspicious_email_domains).post(suspicious_email_domains_post),
)
.route("/phrase-bans", get(phrase_bans).post(phrase_bans_post))
.route("/url-bans", get(url_bans).post(url_bans_post))
.route(
@@ -72,7 +68,6 @@ macro_rules! ban_get {
ban_get!(ip_bans, "ip-bans");
ban_get!(email_bans, "email-bans");
ban_get!(suspicious_email_domains, "suspicious-email-domains");
ban_get!(phrase_bans, "phrase-bans");
ban_get!(url_bans, "url-bans");
ban_get!(file_sha_bans, "file-sha-bans");
@@ -141,7 +136,6 @@ macro_rules! ban_post {
ban_post!(ip_bans_post, "ip-bans");
ban_post!(email_bans_post, "email-bans");
ban_post!(suspicious_email_domains_post, "suspicious-email-domains");
ban_post!(phrase_bans_post, "phrase-bans");
ban_post!(url_bans_post, "url-bans");
ban_post!(file_sha_bans_post, "file-sha-bans");
-11
View File
@@ -116,11 +116,6 @@ async fn execute_single_ban(
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.add_suspicious_email_domain(value, audit_log_reason)
.await
}
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
@@ -143,11 +138,6 @@ async fn execute_single_unban(
let result = match ban_type {
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
"email-bans" => client.unban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.remove_suspicious_email_domain(value, audit_log_reason)
.await
}
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
"url-bans" => client.unban_url(value, audit_log_reason).await,
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
@@ -169,7 +159,6 @@ async fn execute_check(
let result = match ban_type {
"ip-bans" => client.check_ip_ban(value).await,
"email-bans" => client.check_email_ban(value).await,
"suspicious-email-domains" => client.check_suspicious_email_domain(value).await,
"phrase-bans" => client.check_phrase_ban(value).await,
"url-bans" => client.check_url_ban(value).await,
"file-sha-bans" => client.check_file_sha_ban(value).await,
+597
View File
@@ -0,0 +1,597 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{
client::ApiError,
types::{
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::BTreeMap;
const PRICE_ID_MAX_CHARS: usize = 255;
const INFO_URL_MAX_CHARS: usize = 2048;
pub(super) fn build_billing_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let premium_product_name = if form.contains_key("billing_premium_product_name") {
Some(parse_premium_product_name(
form.clean("billing_premium_product_name"),
)?)
} else {
None
};
let premium_info_url = if form.contains_key("billing_premium_info_url") {
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
} else {
None
};
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
AppBrandingConfigUpdateRequest {
premium_product_name,
premium_info_url,
..Default::default()
}
});
let prices = if form.contains_key("billing_price_currency") {
Some(parse_price_rows(form)?)
} else {
None
};
let default_currency = if form.contains_key("billing_default_currency") {
Some(
form.clean("billing_default_currency")
.map(|value| parse_currency(&value))
.transpose()?,
)
} else {
None
};
let country_currencies = if form.contains_key("billing_country_currencies") {
Some(parse_country_currencies(
form.first("billing_country_currencies").unwrap_or(""),
)?)
} else {
None
};
let legacy_prices = if form.contains_key("billing_legacy_prices") {
Some(parse_legacy_prices(
form.first("billing_legacy_prices").unwrap_or(""),
)?)
} else {
None
};
if let Some(Some(prices)) = &prices {
if let Some(Some(currency)) = &default_currency
&& !prices.contains_key(currency)
{
return Err(format!(
"Default currency {currency} has no row in the price table"
));
}
if let Some(Some(countries)) = &country_currencies
&& let Some((country, currency)) = countries
.iter()
.find(|(_, currency)| !prices.contains_key(*currency))
{
return Err(format!(
"{country} maps to {currency}, which has no row in the price table"
));
}
}
Ok(InstanceConfigUpdateRequest {
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
branding: Some(branding),
..Default::default()
}),
billing: Some(InstanceBillingUpdateRequest {
enabled: parse_tri_state(form, "billing_enabled")?,
stripe_secret_key: secret_update(
form,
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
),
stripe_webhook_secret: secret_update(
form,
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
),
default_currency,
prices,
country_currencies,
legacy_prices,
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
}),
..Default::default()
})
}
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
if !form.contains_key(key) {
return Ok(None);
}
match form.first(key).map(str::trim).unwrap_or("") {
TRI_STATE_DEFAULT => Ok(Some(None)),
TRI_STATE_ON => Ok(Some(Some(true))),
TRI_STATE_OFF => Ok(Some(Some(false))),
other => Err(format!("Invalid choice \"{other}\" for {key}")),
}
}
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
match result {
Ok(_) => FlashData::success("Premium and billing settings updated"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update billing config");
match validation_message(&error) {
Some(message) => FlashData::error(format!(
"Failed to update premium and billing settings: {message}"
)),
None => FlashData::error("Failed to update premium and billing settings"),
}
}
}
}
fn validation_message(error: &ApiError) -> Option<String> {
let ApiError::Http {
status: 400,
message,
} = error
else {
return None;
};
let body: serde_json::Value = serde_json::from_str(message).ok()?;
let first = body["errors"].as_array().and_then(|errors| errors.first());
let detail = first.and_then(|error| {
let message = error["message"].as_str()?;
Some(
match error["path"].as_str().filter(|path| !path.is_empty()) {
Some(path) => format!("{path}: {message}"),
None => message.to_owned(),
},
)
});
detail.or_else(|| body["message"].as_str().map(str::to_owned))
}
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
match form.clean(key) {
Some(secret) => Some(Some(secret)),
None if form.bool_value(clear_key) => Some(None),
None => None,
}
}
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
match value {
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
)),
other => Ok(other),
}
}
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
&& url::Url::parse(&value).is_ok_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some_and(|h| !h.is_empty())
});
if valid {
Ok(Some(value))
} else {
Err("Premium info URL must be an absolute http or https URL".to_owned())
}
}
fn parse_currency(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(currency)
} else {
Err(format!(
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
value.trim()
))
}
}
fn parse_country(value: &str) -> Result<String, String> {
let country = value.trim().to_ascii_uppercase();
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(country)
} else {
Err(format!(
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
value.trim()
))
}
}
fn parse_price_id(value: &str) -> Result<String, String> {
let id = value.trim();
let valid = id.len() <= PRICE_ID_MAX_CHARS
&& id.strip_prefix("price_").is_some_and(|rest| {
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
});
if valid {
Ok(id.to_owned())
} else {
Err(format!(
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
))
}
}
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
match value
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(id) => parse_price_id(id).map(Some),
None => Ok(None),
}
}
fn parse_price_rows(
form: &MultiValueForm,
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
let currencies = form.values("billing_price_currency");
let column = |key: &str, index: usize| form.values(key).get(index);
let mut prices = BTreeMap::new();
for (index, currency) in currencies.iter().enumerate() {
if currency.trim().is_empty() {
continue;
}
let currency = parse_currency(currency)?;
let set = BillingPriceSet {
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
};
if set.is_empty() {
return Err(format!("{currency} needs at least one price ID"));
}
if prices.insert(currency.clone(), set).is_some() {
return Err(format!(
"{currency} appears more than once in the price table"
));
}
}
if prices.len() > BILLING_MAX_CURRENCIES {
return Err(format!(
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
));
}
Ok((!prices.is_empty()).then_some(prices))
}
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('=')
.map(|(key, value)| (key.trim(), value.trim()))
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
})
}
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
let mut countries = BTreeMap::new();
for line in key_value_lines(value) {
let (country, currency) = line?;
let country = parse_country(country)?;
let currency = parse_currency(currency)?;
if countries.insert(country.clone(), currency).is_some() {
return Err(format!("{country} is mapped more than once"));
}
}
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
return Err(format!(
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
));
}
Ok((!countries.is_empty()).then_some(countries))
}
fn parse_legacy_slot(value: &str) -> Result<String, String> {
let invalid = || {
format!(
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
)
};
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
let slot = slot.to_ascii_lowercase();
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
return Err(invalid());
}
let currency = parse_currency(currency).map_err(|_| invalid())?;
Ok(format!("{slot}_{currency}"))
}
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
for line in key_value_lines(value) {
let (slot, ids) = line?;
let slot = parse_legacy_slot(slot)?;
let entry = legacy.entry(slot.clone()).or_default();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
let id = parse_price_id(id)?;
if !entry.contains(&id) {
entry.push(id);
}
}
if entry.is_empty() {
return Err(format!("{slot} needs at least one price ID"));
}
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
return Err(format!(
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
));
}
}
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
return Err(format!(
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
));
}
Ok((!legacy.is_empty()).then_some(legacy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
fn full_form(extra: &str) -> MultiValueForm {
let base = "billing_premium_product_name=%20Gold%20\
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
&billing_enabled=on\
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
&billing_stripe_secret_key=\
&billing_stripe_webhook_secret=whsec_new\
&billing_default_currency=gbp\
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
}
#[test]
fn full_billing_form_builds_the_expected_patch() {
let update = build_billing_update(&full_form("")).expect("valid form");
let value = serde_json::to_value(&update).expect("serializable");
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"app_public": {
"branding": {
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
}
},
"billing": {
"enabled": true,
"stripe_webhook_secret": "whsec_new",
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
},
"SEK": {
"monthly": "price_1SekM",
"yearly": "price_1SekY",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP", "SE": "SEK"},
"legacy_prices": {
"monthly_GBP": ["price_1OldA", "price_1OldB"],
"yearly_SEK": ["price_1OldC"]
},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
}
})
);
}
#[test]
fn blank_fields_clear_and_the_default_choice_sends_null() {
let form = MultiValueForm::parse(
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
&billing_stripe_webhook_secret=\
&billing_default_currency=\
&billing_price_currency=&billing_price_monthly=price_1Ignored\
&billing_country_currencies=&billing_legacy_prices=",
);
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
assert_eq!(
value,
json!({
"app_public": {
"branding": {"premium_product_name": null, "premium_info_url": null}
},
"billing": {
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": null,
"country_currencies": null,
"legacy_prices": null
}
})
);
}
#[test]
fn a_new_secret_wins_over_the_clear_checkbox() {
let form = MultiValueForm::parse(
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
);
let billing = build_billing_update(&form)
.expect("valid form")
.billing
.expect("billing");
assert_eq!(
billing.stripe_secret_key,
Some(Some("sk_live_x".to_owned()))
);
assert_eq!(billing.stripe_webhook_secret, None);
}
#[test]
fn absent_form_keys_leave_their_fields_untouched() {
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
.expect("valid form");
assert!(update.app_public.is_none());
assert_eq!(
serde_json::to_value(update.billing).unwrap(),
json!({"enabled": false})
);
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
}
#[test]
fn invalid_input_is_rejected_with_a_message() {
let cases: &[(&str, &str)] = &[
(
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
"http or https",
),
("billing_premium_info_url=example.com", "http or https"),
("billing_default_currency=GB", "Invalid currency"),
("billing_enabled=true", "Invalid choice"),
("billing_automatic_tax=maybe", "Invalid choice"),
(
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
"Invalid currency",
),
(
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
"Invalid Stripe price ID",
),
(
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
"Invalid Stripe price ID",
),
("billing_price_currency=GBP", "needs at least one price ID"),
(
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
"more than once",
),
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
("billing_country_currencies=SE", "KEY=VALUE"),
(
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
"mapped more than once",
),
(
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
"Invalid legacy price slot",
),
(
"billing_legacy_prices=monthly_GBP%3D",
"needs at least one price ID",
),
(
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
"Default currency EUR has no row",
),
(
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
"SE maps to SEK",
),
];
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
let emoji_name = format!(
"billing_premium_product_name=Gold{}",
"%F0%9F%92%8E".repeat(20)
);
let long_case = [
(long_name.as_str(), "at most 40"),
(emoji_name.as_str(), "at most 40"),
];
for (body, expected) in long_case.iter().chain(cases.iter()) {
let error =
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
assert!(error.contains(expected), "{body}: {error}");
}
}
#[test]
fn premium_name_limit_counts_utf16_units() {
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
assert_eq!(name.chars().count(), 40);
assert!(parse_premium_product_name(Some(name)).is_err());
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
assert_eq!(
parse_premium_product_name(Some(fits.clone())),
Ok(Some(fits))
);
}
#[test]
fn country_currencies_are_not_cross_checked_without_a_price_table() {
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
let billing = build_billing_update(&form).unwrap().billing.unwrap();
assert_eq!(
billing.country_currencies,
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
);
}
#[test]
fn validation_errors_surface_the_first_api_message() {
let error = ApiError::Http {
status: 400,
message: json!({
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
})
.to_string(),
};
assert_eq!(
validation_message(&error).as_deref(),
Some("billing.enabled: Switch the premium model to mirror first")
);
let server_error = ApiError::Http {
status: 500,
message: "{}".to_owned(),
};
assert_eq!(validation_message(&server_error), None);
}
}
+11 -9
View File
@@ -8,12 +8,15 @@ use crate::{
flash::{self, FlashData},
},
state::AppState,
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
templates::{
self,
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
},
};
use axum::{
Form, Router,
extract::{FromRequest, Query, Request, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
@@ -46,10 +49,11 @@ async fn gift_codes_page(
Query(query): Query<GiftCodesQuery>,
) -> Response {
let config = state.config();
if config.self_hosted {
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let premium = GiftCodesPremium::from_branding(
config.self_hosted,
state.premium_branding(&client).await.as_ref(),
);
let generated_codes: Option<Vec<String>> = query
.codes
@@ -60,6 +64,7 @@ async fn gift_codes_page(
config,
&auth.0,
&csrf.0.0,
&premium,
generated_codes.as_deref(),
);
Html(markup.into_string()).into_response()
@@ -72,9 +77,6 @@ async fn gift_codes_post(
) -> Response {
let config = state.config();
let base = &config.base_path;
if config.self_hosted {
return Redirect::to(&format!("{base}/dashboard")).into_response();
}
let form: GiftCodesForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
+1
View File
@@ -5,6 +5,7 @@ pub mod applications;
pub mod auth;
pub mod bans;
mod bans_actions;
mod billing_actions;
pub mod codes;
pub mod discovery;
mod guild_tabs;
+5
View File
@@ -221,6 +221,11 @@ async fn instance_config_page(
.get_instance_config()
.await
.log_error("load instance config");
if let Some(instance_config) = &instance_config {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
let limit_config = client
.get_limit_config()
.await
+228 -386
View File
@@ -6,21 +6,20 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
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, PushServiceDeliveryConfigUpdateRequest, 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,
@@ -193,7 +192,9 @@ pub async fn instance_config_post(
}
"update_policy" => {
let update = build_policy_update(&form);
instance_config_result(client.update_instance_config(&update).await)
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
instance_config_result(result)
}
"update_integrations" => {
let update = build_integrations_update(&form);
@@ -203,11 +204,23 @@ 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) {
"update_billing" => match super::billing_actions::build_billing_update(&form) {
Ok(update) => {
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
super::billing_actions::billing_result(result)
}
Err(message) => FlashData::error(message),
},
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_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),
},
@@ -338,6 +351,17 @@ pub async fn instance_config_post(
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn remember_premium_branding(
state: &AppState,
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
) {
if let Ok(instance_config) = result {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
}
fn render_registration_url_list_response(
config: &AdminConfig,
csrf_token: &str,
@@ -452,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;
@@ -493,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_push_service_delivery_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 {
@@ -543,138 +557,76 @@ 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,
});
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
}),
..Default::default()
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
fn build_domain_migration_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")),
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
form.first("domain_migration_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(
anonymous_rollout_basis_points: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
}),
..Default::default()
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
captcha: Some(CaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("captcha_enabled")),
cost: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
"captcha_cost",
"Cost",
*CAPTCHA_COST_RANGE.start(),
*CAPTCHA_COST_RANGE.end(),
)?,
rollout_salt: parse_push_service_delivery_rollout_salt(
max_counter: parse_form_number(
form,
"push_service_delivery_rollout_salt",
"captcha_max_counter",
"Maximum counter",
*CAPTCHA_MAX_COUNTER_RANGE.start(),
*CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
@@ -736,6 +688,7 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
theme_color: optional("app_theme_color"),
status_page_url: optional("app_status_page_url"),
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
..Default::default()
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
@@ -787,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,
@@ -795,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),
@@ -868,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()),
@@ -959,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()
}
@@ -1286,75 +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": [],
"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!(
@@ -1415,192 +1265,184 @@ 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)
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&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
.domain_migration
.expect("domain migration update");
assert_eq!(update.enabled, Some(true));
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!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
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()
])
);
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]
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);
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_domain_migration_update(&form).expect("valid form");
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"
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"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",
),
(
"domain_migration_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"domain_migration_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"),
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
assert_eq!(
serde_json::to_value(&unchecked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
);
let checked = build_push_relay_update(&MultiValueForm::parse(
b"_csrf=token&push_relay_consent_accepted=true",
));
assert_eq!(
serde_json::to_value(&checked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
);
}
#[test]
fn build_captcha_update_reads_the_switch_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"captcha_enabled=true&captcha_cost=%202000%20&captcha_max_counter=400",
);
let update = build_captcha_update(&form)
.expect("valid form")
.captcha
.expect("captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_captcha_update_turns_the_check_off_when_the_box_is_unchecked() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"captcha": {"enabled": false}})
);
}
#[test]
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"captcha_cost=999",
"Cost must be a whole number between 1000 and 20000",
),
(
"captcha_cost=20001",
"Cost must be a whole number between 1000 and 20000",
),
(
"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_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
let form = MultiValueForm::parse(
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
);
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
);
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+50 -6
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
admin_flags, api::client::AdminApiClient, middleware::flash::FlashData,
admin_flags,
api::client::{AdminApiClient, ApiError},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::HashSet;
@@ -303,11 +305,53 @@ pub async fn dispatch(
"Failed to schedule user deletion",
)
}
"cancel_deletion" => DispatchOutcome::from_result(
client.cancel_deletion(user_id).await,
"User deletion cancelled successfully",
"Failed to cancel user deletion",
),
"cancel_deletion" => {
let Some(expected) = get("expected_pending_deletion_at") else {
return DispatchOutcome::error(
"The pending deletion is missing from the form. Reload and review.",
);
};
if !form.bool_value("confirm") {
return DispatchOutcome::error(
"Confirm whose deletion you are cancelling before submitting",
);
}
let Some(private_reason) = get("private_reason") else {
return DispatchOutcome::error("A private reason is required to cancel a deletion");
};
let notify_user = form.bool_value("notify_user");
match client
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
.await
{
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The pending deletion changed since this page loaded. Reload and review.",
),
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
DispatchOutcome::error("Failed to cancel user deletion")
}
}
}
"annotate_ban" => {
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
return DispatchOutcome::error("The ban audit log entry is missing from the form");
};
let Some(note) = get("note") else {
return DispatchOutcome::error("Note is required");
};
match client.annotate_ban(user_id, &ban_audit_log_id, &note).await {
Ok(()) => DispatchOutcome::success("Note added to the ban"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The ban changed since this page loaded. Reload and review.",
),
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
DispatchOutcome::error("Failed to add the note to the ban")
}
}
}
"change_dob" => {
let Some(dob) = get("date_of_birth") else {
return DispatchOutcome::error("Date of birth is required");
+36
View File
@@ -111,11 +111,47 @@ pub async fn render(
query.delete_all_messages_channel_count.unwrap_or(0),
query.delete_all_messages_message_count.unwrap_or(0),
));
let deletion_scheduler = match u.deletion_scheduled_by.as_deref() {
Some(scheduler_id) if u.pending_deletion_at.is_some() && scheduler_id != u.id => {
client
.get_user_by_id(scheduler_id)
.await
.log_error("load deletion scheduler")
}
_ => None,
};
let ban_logs = if u.temp_banned_until.is_some()
&& acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW)
{
client
.search_audit_logs(&SearchAuditLogsParams {
query: None,
admin_user_id: None,
target_id: Some(user_id.to_owned()),
target_type: Some("user".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit: 100,
offset: 0,
})
.await
.log_error("load ban audit logs")
.map(|response| response.logs)
.unwrap_or_default()
} else {
Vec::new()
};
let context = tabs::moderation::ModerationContext {
deletion_scheduler: deletion_scheduler.as_ref(),
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
};
Some(tabs::moderation::moderation_tab(
config,
&u,
csrf_token,
admin_acls,
&context,
query.message_shred_job_id.as_deref(),
message_shred_status.as_ref(),
delete_all_messages_dry_run,
+118 -33
View File
@@ -4,7 +4,7 @@ use crate::{
acl,
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
types::{AdminUser, PremiumBranding},
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
@@ -22,6 +22,7 @@ use axum::{
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
const DEFAULT_PREMIUM_NAME: &str = "Premium";
#[derive(Deserialize)]
struct UserListQuery {
@@ -87,32 +88,47 @@ async fn users_list(
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
}
};
let badge = async {
if searching {
self_hosted_premium_badge_name(&state, &client).await
} else {
None
}
};
let (results, badge_name) = tokio::join!(results, badge);
let result_users = results.as_ref().map(|r| r.0.as_slice());
let has_more = results.as_ref().is_some_and(|r| r.1);
let premium_badge_name = match result_users {
Some(users) if !users.is_empty() => badge_name,
_ => None,
};
let markup = templates::pages::users_list::users_list_page(
config,
&auth.0,
@@ -120,11 +136,34 @@ async fn users_list(
result_users,
has_more,
can_view_email,
premium_badge_name.as_deref(),
is_results_fragment,
);
Html(markup.into_string()).into_response()
}
async fn self_hosted_premium_badge_name(
state: &AppState,
client: &AdminApiClient,
) -> Option<String> {
if !state.config().self_hosted {
return None;
}
premium_badge_name(state.premium_branding(client).await.as_ref())
}
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
match branding {
Some(branding) => branding.premium_enabled.then(|| {
branding
.name
.clone()
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
}),
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
}
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
@@ -148,10 +187,15 @@ async fn user_detail(
let is_detail_fragment = htmx::targets(&headers, "main-content");
let active_tab = query.tab.as_deref().unwrap_or("overview");
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user detail");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user detail")
},
self_hosted_premium_badge_name(&state, &client)
);
let tq = to_tab_query(&query);
let admin_acls = auth
.0
@@ -167,6 +211,7 @@ async fn user_detail(
} else {
None
};
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
&auth.0,
@@ -174,6 +219,7 @@ async fn user_detail(
&user_id,
active_tab,
tab_body,
premium_badge_name.as_deref(),
is_detail_fragment,
);
Html(markup.into_string()).into_response()
@@ -275,18 +321,29 @@ async fn user_peek(
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user peek");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user peek")
},
self_hosted_premium_badge_name(&state, &client)
);
let admin_acls = auth
.0
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let premium_badge_name = user.as_ref().and(badge_name);
let markup = match user {
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
config,
u,
admin_acls,
premium_badge_name.as_deref(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "User not found." }
},
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
url.push_str(&urlencoding::encode(value));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
}
}
+49 -2
View File
@@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use std::sync::Arc;
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
},
config::AdminConfig,
};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct AppState {
@@ -11,6 +22,7 @@ pub struct AppState {
struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
}
impl AppState {
@@ -23,6 +35,7 @@ impl AppState {
inner: Arc::new(AppStateInner {
config,
http_client,
premium_branding: Mutex::new(None),
}),
}
}
@@ -34,6 +47,40 @@ impl AppState {
pub fn http_client(&self) -> &reqwest::Client {
&self.inner.http_client
}
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
let cache = self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.as_ref()
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
.map(|(_, branding)| branding.clone())
}
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
*self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
}
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
if let Some(branding) = self.cached_premium_branding() {
return Some(branding);
}
let branding = PremiumBranding::from_discovery(
&client
.get_instance_premium_discovery()
.await
.log_error("load premium branding")?,
);
self.remember_premium_branding(branding.clone());
Some(branding)
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
@@ -13,12 +13,39 @@ struct BadgeDef {
tooltip: String,
}
fn premium_tooltip(
premium_type: i32,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
) -> Option<String> {
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
None => "Fluxer Plutonium".into(),
}
})
}
pub fn user_profile_badges(
static_cdn_endpoint: &str,
flags: u64,
premium_type: Option<i32>,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
size_sm: bool,
) -> Markup {
let cdn = static_cdn_endpoint.trim_end_matches('/');
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
tooltip: "Fluxer Bug Hunter".into(),
});
}
if !is_self_hosted
&& let Some(pt) = premium_type
if let Some(pt) = premium_type
&& pt != premium_types::NONE
&& let Some(tooltip) =
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
{
let tooltip = if pt == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => {
format!("Fluxer Plutonium subscriber since {since}")
}
None => "Fluxer Plutonium".into(),
}
};
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/plutonium.svg"),
tooltip,
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
user_profile_badges(
"https://static.example.com",
0,
Some(premium_type),
Some("2026-01-01"),
self_hosted,
name,
false,
)
.into_string()
}
#[test]
fn hosted_premium_badges_keep_their_fluxer_labels() {
assert!(
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
);
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
}
#[test]
fn self_hosted_premium_badges_use_the_configured_name() {
let markup = render(true, Some("Gold"), 1);
assert!(markup.contains("Gold subscriber since 2026-01-01"));
assert!(!markup.contains("Plutonium"));
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
assert!(!render(true, None, 1).contains("img"));
}
}
@@ -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",
@@ -255,13 +245,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
],
},
NavSection {
title: "Hosted Features",
title: "Premium",
items: &[item!(
"Gift Codes",
"/gift-codes",
"gift-codes",
[acl::GIFT_CODES_GENERATE],
hosted
[acl::GIFT_CODES_GENERATE]
)],
},
];
@@ -25,7 +25,9 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant {
| "ban_ip"
| "ban_email" => BadgeVariant::Danger,
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" => BadgeVariant::Success,
"update_flags" | "update_features" | "set_acls" | "update_settings" => BadgeVariant::Info,
"update_flags" | "update_features" | "set_acls" | "update_settings" | "annotate_ban" => {
BadgeVariant::Info
}
"delete_message" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
}
-11
View File
@@ -45,17 +45,6 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
active_page: "email-bans",
show_bulk_tools: false,
},
BanConfig {
title: "Suspicious Email Domains",
route: "/suspicious-email-domains",
input_label: "Email Domain",
input_name: "domain",
input_type: "text",
placeholder: "mail.ru",
entity_name: "Domain",
active_page: "suspicious-email-domains",
show_bulk_tools: false,
},
BanConfig {
title: "Phrase Bans",
route: "/phrase-bans",
@@ -410,6 +410,9 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
}
},
))
p class="text-neutral-500 text-sm" {
"Users that already have a pending deletion are skipped and listed as failed with the reason already scheduled. Cancel those from the user page first to schedule them again."
}
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
+73 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::PremiumBranding,
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
@@ -19,21 +20,52 @@ use maud::{Markup, html};
pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10;
pub struct GiftCodesPremium {
pub name: String,
pub needs_mirror_mode: bool,
}
impl GiftCodesPremium {
pub fn from_branding(self_hosted: bool, branding: Option<&PremiumBranding>) -> Self {
let default_name = if self_hosted { "Premium" } else { "Plutonium" };
Self {
name: branding
.and_then(|branding| branding.name.as_deref())
.unwrap_or(default_name)
.to_owned(),
needs_mirror_mode: self_hosted
&& branding.is_some_and(|branding| !branding.premium_enabled),
}
}
}
pub fn gift_codes_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
premium: &GiftCodesPremium,
generated_codes: Option<&[String]>,
) -> Markup {
let base = &config.base_path;
let codes_value = generated_codes.map(|c| c.join("\n")).unwrap_or_default();
let description = format!(
"Create one-use {} gift URLs with a fixed positive duration. \
Lifetime gifts cannot be generated here.",
premium.name
);
let content = html! {
(page_header(
"Gift Codes",
Some("Create one-use Plutonium gift URLs with a fixed positive \
duration. Lifetime gifts cannot be generated here."),
))
(page_header("Gift Codes", Some(&description)))
@if premium.needs_mirror_mode {
(card(html! {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has " (premium.name)
" and gift codes cannot be generated or redeemed. Switch the premium model to \
Mirror in Instance Config to use gift codes."
}
}))
}
(card(html! {
div class="flex flex-col gap-4" {
@@ -107,3 +139,39 @@ pub fn gift_codes_page(
};
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
PremiumBranding {
name: Some(name.to_owned()),
premium_enabled,
}
}
#[test]
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
let hosted = GiftCodesPremium::from_branding(false, None);
assert_eq!(hosted.name, "Plutonium");
assert!(!hosted.needs_mirror_mode);
let self_hosted = GiftCodesPremium::from_branding(true, None);
assert_eq!(self_hosted.name, "Premium");
assert!(!self_hosted.needs_mirror_mode);
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
assert_eq!(gold.name, "Gold");
assert!(!gold.needs_mirror_mode);
}
#[test]
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
assert!(
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
);
assert!(
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
.needs_mirror_mode
);
}
}
@@ -0,0 +1,652 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{
AppBrandingConfigResponse, BillingCatalogMode, BillingPriceSet, InstanceBillingResponse,
PremiumMode, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
templates::components::{
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, FORM_SELECT_CLASS, checkbox, csrf_input, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
},
section_card::section_card_with_description,
},
};
use maud::{Markup, html};
const PRICE_COLUMNS: [(&str, &str); 4] = [
("billing_price_monthly", "Monthly"),
("billing_price_yearly", "Yearly"),
("billing_price_gift_1_month", "Gift 1 month"),
("billing_price_gift_1_year", "Gift 1 year"),
];
pub fn billing_blockers(
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Vec<&'static str> {
if billing.billing_active {
return Vec::new();
}
let mut blockers = Vec::new();
if matches!(premium_mode, PremiumMode::Everyone) {
blockers.push("the premium model is Everyone, so there is no paid tier to sell");
}
if !billing.effective_enabled {
blockers.push("billing is not enabled");
}
if !billing.stripe_secret_key_set {
blockers.push("no Stripe secret key is set");
}
let has_pair = billing
.prices
.as_ref()
.is_some_and(|prices| prices.values().any(BillingPriceSet::has_recurring_pair));
if billing.catalog_mode == BillingCatalogMode::Operator && !has_pair {
blockers.push("no currency has both a monthly and a yearly price ID");
}
if blockers.is_empty() {
blockers.push(match billing.catalog_mode {
BillingCatalogMode::Env => {
"the environment price catalog has no currency with both a monthly and a yearly price ID"
}
BillingCatalogMode::Operator => "the API reports billing as inactive",
});
}
blockers
}
fn billing_status(billing: &InstanceBillingResponse, premium_mode: PremiumMode) -> Markup {
let blockers = billing_blockers(billing, premium_mode);
html! {
div class="space-y-2" {
div class="flex flex-wrap items-center gap-2" {
@if billing.billing_active {
(badge("Billing active", BadgeVariant::Success))
} @else {
(badge("Billing inactive", BadgeVariant::Default))
}
@match billing.catalog_mode {
BillingCatalogMode::Operator => {
(badge("Catalog: price table", BadgeVariant::Default))
}
BillingCatalogMode::Env => {
(badge("Catalog: environment", BadgeVariant::Default))
}
}
(secret_badge(
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
BadgeVariant::Default,
))
(secret_badge(
"Webhook secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
BadgeVariant::Warning,
))
}
@if !blockers.is_empty() {
p class="text-sm text-neutral-600" {
"Purchases are unavailable because " (blockers.join("; ")) "."
}
}
@if billing.billing_active && !billing.stripe_webhook_secret_set {
p class="text-sm text-amber-700" {
"Without a webhook secret, Stripe events are rejected, so subscriptions never reach accounts."
}
}
}
}
}
fn secret_badge(label: &str, is_set: bool, is_stored: bool, missing: BadgeVariant) -> Markup {
match (is_set, is_stored) {
(_, true) => badge(&format!("{label} set"), BadgeVariant::Success),
(true, false) => badge(&format!("{label} from environment"), BadgeVariant::Success),
(false, false) => badge(&format!("{label} missing"), missing),
}
}
fn secret_field(
name: &str,
clear_name: &str,
label: &str,
is_set: bool,
is_stored: bool,
) -> Markup {
let helper = if !is_stored && is_set {
"Set from the environment. Enter a value to override it, or leave blank to keep using it."
} else {
"Leave blank to keep the current value."
};
html! {
div class="flex flex-col gap-2" {
(form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
autocomplete="new-password";
},
))
@if is_stored {
(checkbox(clear_name, "true", "Clear the stored value", false, true))
}
}
}
}
fn tri_state_value(value: Option<bool>) -> &'static str {
match value {
None => TRI_STATE_DEFAULT,
Some(true) => TRI_STATE_ON,
Some(false) => TRI_STATE_OFF,
}
}
fn tri_state_select(
name: &str,
label: &str,
default_label: &str,
stored: Option<bool>,
helper: &str,
) -> Markup {
let selected = tri_state_value(stored);
let options = [
(TRI_STATE_DEFAULT, default_label),
(TRI_STATE_ON, "On"),
(TRI_STATE_OFF, "Off"),
];
form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
div class="relative" {
select id=(name) name=(name) class=(FORM_SELECT_CLASS) {
@for (value, display) in options {
option value=(value) selected[value == selected] { (display) }
}
}
(select_chevron())
}
},
)
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn checkout_options(billing: &InstanceBillingResponse) -> Markup {
let automatic_tax = format!(
"Calculates tax at checkout. Needs Stripe Tax activated and a head office address in the Stripe dashboard. Currently {}.",
on_off(billing.effective_automatic_tax)
);
let tax_id = format!(
"Lets buyers add a VAT or other tax ID at checkout. Pair it with automatic tax. Currently {}.",
on_off(billing.effective_tax_id_collection)
);
let terms = format!(
"Buyers must accept your terms of service at checkout. Needs a terms of service URL in the Stripe dashboard public details. Currently {}.",
on_off(billing.effective_terms_consent_required)
);
html! {
div class="space-y-4" {
h4 class="text-sm font-medium text-neutral-900" { "Checkout options" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
(tri_state_select(
"billing_automatic_tax",
"Automatic tax",
"Use default",
billing.automatic_tax,
&automatic_tax,
))
(tri_state_select(
"billing_tax_id_collection",
"Tax ID collection",
"Use default",
billing.tax_id_collection,
&tax_id,
))
(tri_state_select(
"billing_terms_consent_required",
"Terms consent",
"Use default",
billing.terms_consent_required,
&terms,
))
}
p class="text-xs text-neutral-500" {
"Members manage and cancel subscriptions in the Stripe customer portal. It only opens after you save its \
settings once in the Stripe dashboard under Settings, Billing, Customer portal."
}
}
}
}
fn price_cell(name: &str, label: &str, value: Option<&str>) -> Markup {
html! {
td class="px-2 py-2" {
input type="text" name=(name) value=(value.unwrap_or(""))
placeholder="price_..." aria-label=(label)
autocomplete="off" spellcheck="false"
class=(FORM_INPUT_CLASS);
}
}
}
fn price_row(currency: &str, set: &BillingPriceSet) -> Markup {
let values = [
set.monthly.as_deref(),
set.yearly.as_deref(),
set.gift_1_month.as_deref(),
set.gift_1_year.as_deref(),
];
html! {
tr {
td class="px-2 py-2" {
input type="text" name="billing_price_currency" value=(currency)
placeholder="GBP" maxlength="3" aria-label="Currency"
autocomplete="off" spellcheck="false"
class={(FORM_INPUT_CLASS) " w-24 uppercase"};
}
@for ((name, label), value) in PRICE_COLUMNS.iter().zip(values) {
(price_cell(name, label, value))
}
}
}
}
fn price_table(billing: &InstanceBillingResponse) -> Markup {
let empty = BillingPriceSet::default();
html! {
div class="space-y-2" {
h4 class="text-sm font-medium text-neutral-900" { "Prices" }
p class="text-xs text-neutral-500" {
"One row per currency, using Stripe price IDs from your own account. Monthly and yearly are the \
subscription prices; the gift prices are one-time prices for buying gifts. Clear a currency to \
remove its row. Leave the table empty to use the prices from environment variables."
}
div class="overflow-x-auto" {
table class="min-w-full text-sm" {
thead {
tr class="text-left text-xs text-neutral-500" {
th class="px-2 py-1 font-medium" { "Currency" }
@for (_, label) in PRICE_COLUMNS {
th class="px-2 py-1 font-medium" { (label) }
}
}
}
tbody {
@if let Some(prices) = &billing.prices {
@for (currency, set) in prices {
(price_row(currency, set))
}
}
(price_row("", &empty))
}
}
}
}
}
}
fn country_currencies_text(billing: &InstanceBillingResponse) -> String {
billing
.country_currencies
.iter()
.flatten()
.map(|(country, currency)| format!("{country}={currency}"))
.collect::<Vec<_>>()
.join("\n")
}
fn legacy_prices_text(billing: &InstanceBillingResponse) -> String {
billing
.legacy_prices
.iter()
.flatten()
.flat_map(|(slot, ids)| ids.iter().map(move |id| format!("{slot}={id}")))
.collect::<Vec<_>>()
.join("\n")
}
pub fn premium_billing_section(
base: &str,
csrf_token: &str,
branding: &AppBrandingConfigResponse,
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Markup {
let enabled_helper = format!(
"Use environment setting follows FLUXER_STRIPE_ENABLED or the config file. Billing is currently {}.",
on_off(billing.effective_enabled)
);
section_card_with_description(
"Premium & Billing",
"Name the premium tier and sell it through your own Stripe account. Subscriptions and gift purchases need \
the Mirror premium model, a Stripe secret key and at least one currency with monthly and yearly prices.",
html! {
form method="post" action={(base) "/instance-config?action=update_billing"}
data-admin-result-form="true" {
(csrf_input(csrf_token))
div class="space-y-8" {
div class="space-y-4" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium tier" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_premium_product_name",
"Premium name",
&branding.premium_product_name,
"Premium",
))
(text_input(
"billing_premium_info_url",
"Premium info URL",
branding.premium_info_url.as_deref().unwrap_or(""),
"https://example.com/premium",
))
}
p class="text-xs text-neutral-500" {
"Clients show this name wherever the premium tier is mentioned. Clear it to use the default. \
The info URL is an optional page that describes the tier."
}
@if matches!(premium_mode, PremiumMode::Everyone) {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has premium limits and clients hide \
premium. Switch the premium model to Mirror to sell subscriptions or redeem gift codes."
}
}
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Stripe" }
(billing_status(billing, premium_mode))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
billing.enabled,
&enabled_helper,
))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(secret_field(
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
))
(secret_field(
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
"Stripe webhook signing secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
))
}
(form_field_group(
"Webhook URL",
"billing_webhook_url",
false,
None,
Some("Add this endpoint in the Stripe dashboard, then paste its signing secret above."),
html! {
input type="text" id="billing_webhook_url" value=(billing.webhook_url)
readonly class=(FORM_INPUT_CLASS);
},
))
(checkout_options(billing))
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Catalog" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_default_currency",
"Default currency",
billing.default_currency.as_deref().unwrap_or(""),
"GBP",
))
}
p class="text-xs text-neutral-500" {
"Used when a buyer's country has no mapping below. Leave blank to use the first currency in the table."
}
(price_table(billing))
div class="grid grid-cols-1 gap-4 lg:grid-cols-2" {
div class="space-y-2" {
(textarea_input(
"billing_country_currencies",
"Country currencies",
"SE=SEK\nGB=GBP",
&country_currencies_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"One COUNTRY=CURRENCY per line, using 2-letter country codes. Each currency needs a row in the table."
}
}
div class="space-y-2" {
(textarea_input(
"billing_legacy_prices",
"Legacy prices",
"monthly_GBP=price_...",
&legacy_prices_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"Older price IDs that existing subscribers may still be on, one SLOT_CURRENCY=price ID per line. \
Repeat a slot for several IDs. Slots are monthly, yearly, gift_1_month and gift_1_year."
}
}
}
}
(form_actions(html! {
(submit_button("Save premium & billing"))
}))
}
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn operator_billing() -> InstanceBillingResponse {
InstanceBillingResponse {
enabled: Some(true),
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
default_currency: Some("GBP".to_owned()),
prices: Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1GbpM".to_owned()),
yearly: Some("price_1GbpY".to_owned()),
gift_1_month: None,
gift_1_year: Some("price_1GbpG".to_owned()),
},
)])),
country_currencies: Some(BTreeMap::from([
("GB".to_owned(), "GBP".to_owned()),
("IE".to_owned(), "GBP".to_owned()),
])),
legacy_prices: Some(BTreeMap::from([(
"monthly_GBP".to_owned(),
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
)])),
billing_active: true,
stripe_serviceable: true,
catalog_mode: BillingCatalogMode::Operator,
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
automatic_tax: None,
tax_id_collection: Some(true),
terms_consent_required: Some(false),
effective_automatic_tax: false,
effective_tax_id_collection: true,
effective_terms_consent_required: false,
}
}
fn branding(name: &str) -> AppBrandingConfigResponse {
AppBrandingConfigResponse {
premium_product_name: name.to_owned(),
premium_info_url: Some("https://example.com/gold".to_owned()),
..Default::default()
}
}
#[test]
fn section_renders_every_field_and_one_empty_price_row() {
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Gold"),
&operator_billing(),
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
assert!(markup.contains("data-admin-result-form=\"true\""));
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
assert!(markup.contains("name=\"billing_automatic_tax\""));
assert!(markup.contains("name=\"billing_tax_id_collection\""));
assert!(markup.contains("name=\"billing_terms_consent_required\""));
assert!(markup.contains("Customer portal"));
assert!(markup.contains("name=\"billing_premium_product_name\""));
assert!(markup.contains("value=\"Gold\""));
assert!(markup.contains("value=\"https://example.com/gold\""));
assert!(markup.contains("name=\"billing_enabled\""));
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
assert!(markup.contains("Billing active"));
assert!(!markup.contains("Purchases are unavailable"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
assert_eq!(
markup.matches("name=\"billing_price_gift_1_year\"").count(),
2
);
assert!(markup.contains("value=\"price_1GbpG\""));
assert!(markup.contains("GB=GBP\nIE=GBP"));
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
assert!(!markup.contains("Plutonium"));
assert!(!markup.contains("sk_"));
}
#[test]
fn unset_secrets_have_no_clear_checkbox() {
let billing = InstanceBillingResponse::default();
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Everyone,
)
.into_string();
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
assert!(markup.contains("Switch the premium model to Mirror"));
assert!(markup.contains("Catalog: environment"));
}
#[test]
fn env_secrets_are_labelled_and_cannot_be_cleared() {
let billing = InstanceBillingResponse {
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
..Default::default()
};
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("Stripe secret key from environment"));
assert!(markup.contains("Webhook secret from environment"));
assert!(markup.contains("Set from the environment"));
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
}
#[test]
fn tri_state_selects_reflect_the_stored_value() {
let render = |stored| {
tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
stored,
"",
)
.into_string()
};
assert!(
render(None)
.contains("<option value=\"default\" selected>Use environment setting</option>")
);
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
}
#[test]
fn blockers_explain_why_billing_is_inactive() {
let mut billing = InstanceBillingResponse::default();
assert_eq!(
billing_blockers(&billing, PremiumMode::Everyone),
vec![
"the premium model is Everyone, so there is no paid tier to sell",
"billing is not enabled",
"no Stripe secret key is set",
]
);
billing.effective_enabled = true;
billing.stripe_secret_key_set = true;
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec![
"the environment price catalog has no currency with both a monthly and a yearly price ID"
]
);
billing.catalog_mode = BillingCatalogMode::Operator;
billing.prices = Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1A".to_owned()),
..Default::default()
},
)]));
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec!["no currency has both a monthly and a yearly price ID"]
);
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
}
}
@@ -2,12 +2,13 @@
use crate::{
api::types::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
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, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -23,6 +24,7 @@ use crate::{
section_card::{section_card_simple, section_card_with_description},
},
layout::admin_layout,
pages::instance_billing::premium_billing_section,
},
utils::timestamps::format_admin_timestamp,
};
@@ -116,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 {
@@ -124,7 +132,25 @@ pub fn instance_config_page(
"Community & policy",
"Community shape, direct messaging, the premium model, and optional embed services.",
html! {
(policy_config_section(base, csrf_token, &instance_config.policy))
(policy_config_section(
base,
csrf_token,
&instance_config.policy,
&instance_config.app_public.branding.premium_product_name,
))
},
))
(config_group(
"Premium & billing",
"The premium tier's name, Stripe credentials and the prices members pay.",
html! {
(premium_billing_section(
base,
csrf_token,
&instance_config.app_public.branding,
&instance_config.billing,
instance_config.policy.premium_mode,
))
},
))
}
@@ -135,6 +161,13 @@ pub fn instance_config_page(
(integrations_config_section(base, csrf_token, &instance_config.integrations))
},
))
(config_group(
"Push notifications",
"Consent for the relay that delivers official mobile app notifications.",
html! {
(push_relay_section(base, csrf_token, &instance_config.push_relay))
},
))
(config_group(
"Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.",
@@ -147,8 +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))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -197,7 +229,12 @@ fn config_group(title: &str, description: &str, content: Markup) -> Markup {
}
}
fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
fn policy_config_section(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> Markup {
section_card_with_description(
"Community & Policy",
"Control whether this instance runs as a single community, whether direct messages and \
@@ -207,7 +244,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
div class="space-y-8" {
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(services_form(base, csrf_token, policy))
}
},
@@ -300,58 +337,14 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
}
}
fn deferred_phone_gate_form(
fn premium_mode_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> 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, policy: &InstancePolicyResponse) -> Markup {
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
html! {
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
@@ -359,8 +352,8 @@ fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyRespon
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_premium_mode", "Premium model", &[
("mirror", "Mirror (Free and Premium tiers)"),
("everyone", "Everyone (every member gets Plutonium limits)"),
("mirror", mirror_label.as_str()),
("everyone", everyone_label.as_str()),
], policy.premium_mode.as_str()))
(form_actions(html! {
(submit_button("Save premium model"))
@@ -478,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
@@ -514,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" }
@@ -981,195 +940,69 @@ fn gateway_rollout_section(
)
}
fn voice_noise_suppression_section(
fn push_relay_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
("Not accepted", 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");
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
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.",
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(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" }
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"push_relay_consent_accepted",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
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,
))
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
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" {
(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_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
(submit_button("Save Push Relay Settings"))
}))
}
}
@@ -1177,70 +1010,93 @@ fn voice_noise_suppression_section(
)
}
fn push_service_delivery_section(
fn domain_migration_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(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 " (push_service_delivery.config_version)
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"domain_migration_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
&domain_migration.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."),
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"domain_migration_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
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."
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
@@ -1248,7 +1104,7 @@ fn push_service_delivery_section(
false,
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
@@ -1258,9 +1114,41 @@ fn push_service_delivery_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.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(
"push_service_delivery_excluded_user_ids",
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.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(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
@@ -1268,17 +1156,18 @@ fn push_service_delivery_section(
false,
))
(entry_count_hint(
push_service_delivery.excluded_user_ids.len(),
domain_migration.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."
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
(submit_button("Save Domain Migration Configuration"))
}))
}
}
@@ -1286,6 +1175,73 @@ fn push_service_delivery_section(
)
}
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 captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse) -> Markup {
let status = if captcha.enabled {
("On", BadgeVariant::Success)
} else {
("Off", BadgeVariant::Default)
};
let estimate = estimate_low_end_solve_seconds(captcha.cost, captcha.max_counter);
section_card_with_description(
"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! {
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(
"captcha_enabled",
"true",
"Require a proof-of-work check",
captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"On by default. Turning it off removes the check from every request."
}
(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."),
))
(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-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"))
}))
}
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -1878,7 +1834,7 @@ fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) ->
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
let description = if limit_config.self_hosted.unwrap_or(false) {
"Self-hosted instance with all premium features enabled. Configure user and guild limits."
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
} else {
"Configure limit rules that control user and guild restrictions based on traits and features."
};
@@ -1900,44 +1856,90 @@ 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 {
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]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
let domain_migration = DomainMigrationConfigResponse {
anonymous_rollout_basis_points: 250,
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()
..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("action=update_domain_migration"));
assert!(markup.contains("domain_migration_enabled"));
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
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"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
};
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn premium_mode_options_use_the_configured_premium_name() {
let markup =
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
.into_string();
assert!(markup.contains("Mirror (Free and Gold tiers)"));
assert!(markup.contains("Everyone (every member gets Gold limits)"));
assert!(!markup.contains("Plutonium"));
}
#[test]
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" {
+1
View File
@@ -17,6 +17,7 @@ pub mod gift_codes;
pub mod guild_detail;
pub mod guild_detail_tabs;
pub mod guilds_list;
pub mod instance_billing;
pub mod instance_config;
pub mod job_detail;
pub mod jobs_list;
@@ -38,11 +38,22 @@ pub fn user_detail_page(
auth: &AuthContext,
user: Option<&AdminUser>,
user_id: &str,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
user_detail_with_tab(config, auth, user, user_id, "overview", None, is_htmx)
user_detail_with_tab(
config,
auth,
user,
user_id,
"overview",
None,
premium_badge_name,
is_htmx,
)
}
#[allow(clippy::too_many_arguments)]
pub fn user_detail_with_tab(
config: &AdminConfig,
auth: &AuthContext,
@@ -50,10 +61,13 @@ pub fn user_detail_with_tab(
user_id: &str,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let content = match user {
Some(user) => render_user_detail(config, auth, user, active_tab, tab_body),
Some(user) => {
render_user_detail(config, auth, user, active_tab, tab_body, premium_badge_name)
}
None => not_found_state("User", user_id, None, None),
};
let title = user
@@ -79,6 +93,7 @@ fn render_user_detail(
user: &AdminUser,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
) -> Markup {
let display_name = user
.global_name
@@ -143,6 +158,7 @@ fn render_user_detail(
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
false,
))
}
@@ -4,13 +4,14 @@ use crate::{
acl,
api::{
client::{ApiError, ApiResult},
types::{AdminUser, MessageShredStatusResponse},
types::{AdminUser, AuditLogEntry, MessageShredStatusResponse},
},
config::AdminConfig,
templates::components::{
form::{csrf_input, danger_button, form_actions, submit_button},
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -51,11 +52,60 @@ const DELETION_REASONS: &[(&str, &str)] = &[
("22", "Impersonation or fake identity"),
];
pub struct CurrentBan<'a> {
pub entry: &'a AuditLogEntry,
pub notes: Vec<&'a AuditLogEntry>,
}
#[derive(Default)]
pub struct ModerationContext<'a> {
pub deletion_scheduler: Option<&'a AdminUser>,
pub current_ban: Option<CurrentBan<'a>>,
}
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
let banned_until = user.temp_banned_until.as_deref()?;
let entry = logs.iter().find(|log| {
log.action == "temp_ban"
&& log.target_id == user.id
&& log.metadata.get("banned_until").map(String::as_str) == Some(banned_until)
})?;
let mut notes: Vec<&AuditLogEntry> = logs
.iter()
.filter(|log| {
log.action == "annotate_ban"
&& log.metadata.get("ban_audit_log_id") == Some(&entry.log_id)
})
.collect();
notes.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Some(CurrentBan { entry, notes })
}
fn deletion_reason_label(code: i32) -> String {
let value = code.to_string();
DELETION_REASONS
.iter()
.find(|(candidate, _)| *candidate == value)
.map_or_else(
|| format!("Reason {code}"),
|(_, label)| (*label).to_owned(),
)
}
fn admin_name(entry: &AuditLogEntry) -> String {
entry.admin_user.as_ref().map_or_else(
|| entry.admin_user_id.clone(),
|admin| admin.username.clone(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn moderation_tab(
config: &AdminConfig,
user: &AdminUser,
csrf_token: &str,
admin_acls: &[String],
context: &ModerationContext<'_>,
message_shred_job_id: Option<&str>,
message_shred_status: Option<&ApiResult<MessageShredStatusResponse>>,
delete_all_messages_dry_run: Option<(u64, u64)>,
@@ -66,8 +116,8 @@ pub fn moderation_tab(
html! {
div class="space-y-6" {
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
(ban_actions_card(base, user, csrf_token))
(deletion_card(base, user, csrf_token))
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
}
@if can_delete_all_messages {
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
@@ -79,10 +129,16 @@ pub fn moderation_tab(
}
}
fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn ban_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
current_ban: Option<&CurrentBan<'_>>,
) -> Markup {
html! {
(card_with_header("Ban Actions", html! {
@if user.temp_banned_until.is_some() {
(current_ban_details(base, user, csrf_token, current_ban))
form method="post"
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
(csrf_input(csrf_token))
@@ -129,16 +185,160 @@ fn ban_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
}
}
fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn current_ban_details(
base: &str,
user: &AdminUser,
csrf_token: &str,
current_ban: Option<&CurrentBan<'_>>,
) -> Markup {
let Some(ban) = current_ban else {
return html! {
p class="mb-4 text-sm text-neutral-500" {
"The audit log entry of this ban could not be loaded, so notes cannot be added here."
}
};
};
html! {
div class="mb-4 space-y-3" {
dl class="space-y-1 text-sm text-neutral-700" {
div {
dt class="inline font-medium" { "Banned by: " }
dd class="inline" {
a href={(base) "/users/" (ban.entry.admin_user_id)} class="underline" {
(admin_name(ban.entry))
}
" on " (format_admin_timestamp(&ban.entry.created_at))
}
}
div {
dt class="inline font-medium" { "Reason: " }
dd class="inline" { (ban.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
}
}
@if !ban.notes.is_empty() {
ul class="space-y-1 text-sm text-neutral-700" {
@for note in &ban.notes {
li {
span class="font-medium" { (admin_name(note)) }
" (" (format_admin_timestamp(&note.created_at)) "): "
(note.audit_log_reason.as_deref().unwrap_or(""))
}
}
}
}
form method="post"
action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} {
(csrf_input(csrf_token))
input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id);
div class="space-y-3" {
(form_label("Add a note to this ban"))
textarea name="note" rows="2" required maxlength="512"
placeholder="Appended to the ban. The original reason is kept."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {}
(form_actions(html! {
(submit_button("Add Note"))
}))
}
}
}
}
}
fn pending_deletion_summary(
base: &str,
user: &AdminUser,
scheduler: Option<&AdminUser>,
) -> (String, Markup) {
let scheduler_name = match (user.deletion_scheduled_by.as_deref(), scheduler) {
(None, _) => "an unrecorded source".to_owned(),
(Some(id), _) if id == user.id => "the user".to_owned(),
(Some(_), Some(scheduler)) => scheduler.username.clone(),
(Some(id), None) => id.to_owned(),
};
let reason = user
.deletion_reason_code
.map_or_else(|| "no reason code".to_owned(), deletion_reason_label);
let due = user
.pending_deletion_at
.as_deref()
.map(format_admin_timestamp)
.unwrap_or_default();
let markup = html! {
dl class="mb-4 space-y-1 text-sm text-neutral-700" {
div {
dt class="inline font-medium" { "Scheduled by: " }
dd class="inline" {
@match user.deletion_scheduled_by.as_deref() {
Some(id) => {
a href={(base) "/users/" (id)} class="underline" { (scheduler_name) }
}
None => { (scheduler_name) }
}
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
" on " (format_admin_timestamp(at))
}
}
}
div {
dt class="inline font-medium" { "Due: " }
dd class="inline" { (due) }
}
div {
dt class="inline font-medium" { "Reason: " }
dd class="inline" { (reason) }
}
@if let Some(public_reason) = &user.deletion_public_reason {
div {
dt class="inline font-medium" { "Public reason: " }
dd class="inline" { (public_reason) }
}
}
@if let Some(private_reason) = &user.deletion_audit_log_reason {
div {
dt class="inline font-medium" { "Private reason: " }
dd class="inline" { (private_reason) }
}
}
}
};
(
format!("Cancel {scheduler_name}'s deletion ({reason}, due {due})"),
markup,
)
}
fn deletion_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
scheduler: Option<&AdminUser>,
) -> Markup {
html! {
(card_with_header("Account Deletion", html! {
@if user.pending_deletion_at.is_some() {
@if let Some(pending) = &user.pending_deletion_at {
@let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
(summary)
form method="post"
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Cancel Deletion"))
}))
input type="hidden" name="expected_pending_deletion_at" value=(pending);
div class="space-y-3" {
(form_label("Private Reason"))
input type="text" name="private_reason" required
placeholder="Why this deletion is being cancelled (audit log)..."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
(danger_button("Cancel Deletion"))
}))
}
}
} @else {
form method="post"
@@ -153,11 +353,12 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(form_label("Reason"))
select name="reason_code"
select name="reason_code" required
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {
option value="" disabled selected { "Choose a reason" }
@for &(value, label) in DELETION_REASONS {
option value=(value) { (label) }
}
@@ -513,3 +714,112 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
});
})();
"#;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn user(extra: Value) -> AdminUser {
let mut value =
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
target.extend(fields.clone());
}
serde_json::from_value(value).expect("valid admin user")
}
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1400000000000000001",
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
"action": action,
"target_id": "1500000000000000001",
"target_type": "user",
"audit_log_reason": reason,
"metadata": metadata,
"created_at": "2026-09-01T10:00:00.000Z"
}))
.expect("valid audit log entry")
}
#[test]
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
let target = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3,
"deletion_public_reason": "Spam",
"deletion_audit_log_reason": "Report batch 12",
"deletion_scheduled_by": "1400000000000000001",
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
assert!(markup.contains("lilith"));
assert!(markup.contains("Report batch 12"));
assert!(
markup.contains(
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
)
);
assert!(markup.contains(r#"name="notify_user" value="true""#));
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
assert!(markup.contains(r#"name="private_reason" required"#));
}
#[test]
fn schedule_form_makes_the_reason_an_explicit_choice() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
}
#[test]
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let logs = vec![
entry(
"3",
"annotate_ban",
"Also sent links",
json!({"ban_audit_log_id": "2"}),
),
entry(
"2",
"temp_ban",
"Regel § 3",
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
),
entry(
"1",
"temp_ban",
"Older ban",
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
),
entry(
"4",
"annotate_ban",
"Old note",
json!({"ban_audit_log_id": "1"}),
),
];
let ban = find_current_ban(&target, &logs).expect("current ban");
assert_eq!(ban.entry.log_id, "2");
assert_eq!(
ban.notes
.iter()
.map(|note| note.log_id.as_str())
.collect::<Vec<_>>(),
["3"]
);
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
assert!(markup.contains("Regel § 3"));
assert!(markup.contains("Also sent links"));
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
assert!(markup.contains("?action=annotate_ban&amp;tab=moderation"));
}
}
@@ -9,7 +9,10 @@ use crate::{
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator,
timestamps::{format_admin_timestamp, snowflake_creation_date},
},
};
use maud::{Markup, html};
@@ -70,6 +73,22 @@ fn render_overview_tab(
@if let Some(reason) = &user.deletion_public_reason {
div class="mt-1" { "Public reason: " (reason) }
}
@if let Some(reason) = &user.deletion_audit_log_reason {
div class="mt-1" { "Private reason: " (reason) }
}
div class="mt-1" {
"Scheduled by "
@match user.deletion_scheduled_by.as_deref() {
Some(id) if id == user.id => { "the user" }
Some(id) => {
a href={(config.base_path) "/users/" (id)} class="underline" { (id) }
}
None => { "an unrecorded source" }
}
@if let Some(at) = user.deletion_scheduled_at.as_deref() {
" on " (format_admin_timestamp(at))
}
}
}
}
}
@@ -293,7 +312,7 @@ fn flags_card(
))
@if user.phone_verification_deferred {
p class="text-sm text-amber-700 dark:text-amber-400" {
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
"Phone verification is deferred: the requirement above is stored but not enforced."
}
}
}
@@ -33,7 +33,12 @@ fn status_badge(user: &AdminUser) -> Markup {
}
}
pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[String]) -> Markup {
pub fn user_peek_fragment(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let display = user
@@ -62,6 +67,7 @@ pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+19 -3
View File
@@ -95,6 +95,7 @@ impl UserListParams {
}
}
#[allow(clippy::too_many_arguments)]
pub fn users_list_page(
config: &AdminConfig,
auth: &AuthContext,
@@ -102,10 +103,18 @@ pub fn users_list_page(
results: Option<&[AdminUser]>,
has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let base = &config.base_path;
let results_markup = render_results(config, params, results, has_more, can_view_email);
let results_markup = render_results(
config,
params,
results,
has_more,
can_view_email,
premium_badge_name,
);
if is_htmx {
return results_markup;
@@ -215,6 +224,7 @@ fn render_results(
results: Option<&[AdminUser]>,
page_has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
html! {
@@ -236,7 +246,7 @@ fn render_results(
"Copy IDs"
}
}
(render_users_table(config, users, can_view_email))
(render_users_table(config, users, can_view_email, premium_badge_name))
script { (maud::PreEscaped(copy_ids_script())) }
@if !params.has_id_lookup() && (params.page > 0 || page_has_more) {
(pagination_controls(base, params, page_has_more))
@@ -301,7 +311,12 @@ fn user_status_badge(user: &AdminUser) -> Markup {
}
}
fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: bool) -> Markup {
fn render_users_table(
config: &AdminConfig,
users: &[AdminUser],
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
table_container(html! {
table class="min-w-full divide-y divide-neutral-200" {
@@ -343,6 +358,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+4
View File
@@ -55,6 +55,10 @@ impl MultiValueForm {
self.fields.contains_key(key)
}
pub fn values(&self, key: &str) -> &[String] {
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
}
pub fn first(&self, key: &str) -> Option<&str> {
self.fields
.get(key)
+153 -42
View File
@@ -37,6 +37,9 @@ fn deserialize_admin_users_me_response() {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["super_admin"],
"traits": ["beta_tester"],
"has_totp": true,
@@ -81,7 +84,8 @@ fn deserialize_flags_as_string_and_number() {
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
"suspicious_activity_flags": 0, "temp_banned_until": null,
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
"deletion_reason_code": null, "deletion_public_reason": null,
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
@@ -113,7 +117,8 @@ fn deserialize_discriminator_int_and_string() {
"premium_lifetime_sequence": null, "suspicious_activity_flags": 0,
"temp_banned_until": null, "pending_deletion_at": null,
"pending_bulk_message_deletion_at": null, "deletion_reason_code": null,
"deletion_public_reason": null, "acls": [], "traits": [],
"deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null, "acls": [], "traits": [],
"has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
@@ -170,6 +175,9 @@ fn deserialize_search_users_response() {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -392,30 +400,33 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"voice_e2ee_scope": "guild_feature_only",
"future_rollout_knob": 3
},
"voice_noise_suppression": {
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
},
"domain_migration": {
"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": [],
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"guild_overrides": [],
"suppression_strength": 80,
"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"]
"future_list_knob": ["a", "b"],
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"push_service_delivery": {
"captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
"cost": 5000,
"max_counter": 1000,
"future_captcha_knob": 1
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -434,7 +445,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"wordmark_url": "https://cdn.example.com/wordmark.svg",
"favicon_url": "https://cdn.example.com/favicon.ico",
"theme_color": "#5865f2",
"future_asset_url": "https://cdn.example.com/future.png"
"future_asset_url": "https://cdn.example.com/future.png",
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
},
"setup": {"configured": true},
"legal": {
@@ -460,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,
@@ -532,6 +531,36 @@ fn deserialize_instance_config_response_with_unknown_keys() {
}
}
},
"billing": {
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false,
"effective_automatic_tax": false,
"effective_tax_id_collection": true,
"effective_terms_consent_required": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1OldA"]},
"billing_active": true,
"stripe_serviceable": true,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"future_billing_knob": 1
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
@@ -541,21 +570,55 @@ 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);
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
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.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));
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
assert!(resp.billing.billing_active);
assert!(resp.media.attachment_decay.effective.enabled);
let ours: types::InstanceConfigResponse =
serde_json::from_str(json).expect("hand-written instance config");
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
assert!(ours.billing.stripe_secret_key_stored);
assert_eq!(ours.billing.tax_id_collection, Some(true));
assert!(ours.billing.effective_tax_id_collection);
assert_eq!(
ours.app_public.branding.premium_info_url.as_deref(),
Some("https://example.com/gold")
);
assert!(ours.billing.billing_active);
assert!(ours.billing.stripe_serviceable);
assert!(!ours.billing.stripe_webhook_secret_set);
assert_eq!(
ours.billing.catalog_mode,
types::BillingCatalogMode::Operator
);
assert_eq!(ours.billing.default_currency.as_deref(), Some("GBP"));
let gbp = &ours.billing.prices.as_ref().expect("prices")["GBP"];
assert_eq!(gbp.gift_1_year.as_deref(), Some("price_1GbpG"));
assert_eq!(gbp.gift_1_month, None);
assert_eq!(
ours.billing.legacy_prices.as_ref().expect("legacy")["monthly_GBP"],
vec!["price_1OldA".to_owned()]
);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_migration_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
@@ -564,6 +627,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(",\n \"future_billing_knob\": 1", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",
@@ -577,6 +641,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
r#"{
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
}"#,
)
.expect("an accepted relay consent must deserialize");
assert!(accepted.relay_consent_accepted);
assert_eq!(
accepted.relay_consent_accepted_at.as_deref(),
Some("2026-09-27T10:11:12.000Z")
);
assert_eq!(
accepted.relay_consent_accepted_by.as_deref(),
Some("1130650140672000000")
);
let empty: types::PushRelayConfigResponse =
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
assert!(!empty.relay_consent_accepted);
assert!(empty.relay_consent_accepted_at.is_none());
assert!(empty.relay_consent_accepted_by.is_none());
}
#[test]
fn serialize_push_relay_update_omits_an_unset_consent() {
assert_eq!(
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
serde_json::json!({})
);
let with = types::PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(true),
};
assert_eq!(
serde_json::to_value(&with).unwrap(),
serde_json::json!({"relay_consent_accepted": true})
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
@@ -757,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
@@ -851,7 +961,8 @@ fn deserialize_webauthn_credentials_response() {
"id": "credential-a",
"name": "YubiKey",
"created_at": "2026-05-26T12:00:00.000Z",
"last_used_at": null
"last_used_at": null,
"rp_id": "fluxer.com"
},
{
"id": "credential-b",
+5
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -273,6 +275,9 @@ fn admin_user() -> Value {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
+10 -16
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -464,7 +466,7 @@ 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",
][..],
),
@@ -954,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,
@@ -1178,26 +1183,15 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"voice_noise_suppression": {
"domain_migration": {
"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",
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"suppression_strength": 80
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
@@ -31,6 +31,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["*"],
"traits": [],
"has_totp": true,
@@ -32,6 +32,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -32,6 +32,9 @@
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
@@ -302,6 +304,9 @@ fn admin_user() -> Value {
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
+1 -1
View File
@@ -50,12 +50,12 @@
"@pkgs/nats": "workspace:*",
"@pkgs/postgres": "workspace:*",
"@pkgs/rate_limit": "workspace:*",
"@pkgs/sms": "workspace:*",
"@pkgs/virus_scan": "workspace:*",
"@pkgs/worker": "workspace:*",
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -11,7 +11,9 @@
},
"dependencies": {
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:"
"altcha-lib": "catalog:",
"itty-time": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "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' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
verify(params: VerifyCaptchaParams): Promise<boolean>;
}
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
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';
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;
const AltchaPayloadSchema = z.object({
challenge: z.object({
parameters: z.looseObject({
algorithm: z.literal(ALTCHA_ALGORITHM),
nonce: z.string().regex(HEX_PATTERN),
salt: z.string().regex(HEX_PATTERN),
cost: z.number().int().positive(),
keyLength: z.number().int().positive(),
keyPrefix: z.string().regex(HEX_PATTERN),
keySignature: z.string().regex(HEX_PATTERN),
expiresAt: z.number().int().positive(),
}),
signature: z.string().regex(HEX_PATTERN),
}),
solution: z.object({
counter: z.number().int().min(0),
derivedKey: z.string().regex(HEX_PATTERN),
time: z.number().optional(),
}),
});
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
export interface AltchaProviderOptions {
hmacSignatureSecret: string;
hmacKeySignatureSecret: string;
cost: number;
maxCounter: number;
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
logger?: LoggerInterface;
now?: () => number;
}
function decodePayload(token: string): AltchaPayload | null {
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
try {
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
export class AltchaProvider {
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
constructor(options: AltchaProviderOptions) {
this.options = options;
this.now = options.now ?? Date.now;
}
async createChallenge(): Promise<Challenge> {
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
return await createChallenge({
algorithm: ALTCHA_ALGORITHM,
cost,
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
deriveKey,
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
hmacSignatureSecret,
hmacKeySignatureSecret,
});
}
async verify({token}: {token: string}): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
const result = await verifySolution({
challenge: payload.challenge,
solution: payload.solution,
deriveKey,
hmacSignatureSecret: this.options.hmacSignatureSecret,
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
});
if (!result.verified) {
this.options.logger?.warn(
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
'ALTCHA verification failed',
);
return false;
}
} catch (error) {
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
return false;
}
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
}
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class HcaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'hcaptcha';
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
protected readonly providerName = 'hCaptcha';
}
@@ -1,105 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {ms} from 'itty-time';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
const DEFAULT_TIMEOUT = ms('10 seconds');
export interface HttpCaptchaProviderOptions {
secretKey: string;
logger?: LoggerInterface;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
hostname?: string;
challenge_ts?: string;
score?: number;
}
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const data = value as Record<string, unknown>;
const errorCodes = data['error-codes'];
return (
typeof data.success === 'boolean' &&
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
(data.hostname === undefined || typeof data.hostname === 'string') &&
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
(data.score === undefined ||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
);
}
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
abstract readonly type: CaptchaProviderType;
protected readonly secretKey: string;
protected readonly logger: LoggerInterface | undefined;
protected readonly timeoutMs: number;
protected readonly userAgent: string;
protected readonly fetchFn: typeof fetch;
protected abstract readonly verifyUrl: string;
protected abstract readonly providerName: string;
constructor(options: HttpCaptchaProviderOptions) {
this.secretKey = options.secretKey;
this.logger = options.logger;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.fetchFn = options.fetchFn ?? fetch;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const body = new URLSearchParams();
body.append('secret', this.secretKey);
body.append('response', token);
if (remoteIp) {
body.append('remoteip', remoteIp);
}
const response = await this.fetchFn(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent,
},
body: body.toString(),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
});
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
return false;
}
const data: unknown = await response.json();
if (!isCaptchaVerifyResponse(data)) {
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
return false;
}
if (!data.success) {
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
return false;
}
return this.validateResponse(data);
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
} else {
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
}
return false;
}
}
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
return true;
}
}
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
const DEFAULT_MINIMUM_SCORE = 0.5;
interface RecaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
score?: number;
}
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
minimumScore?: number;
}
export class RecaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'recaptcha';
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
protected readonly providerName = 'reCAPTCHA';
private readonly minimumScore: number;
constructor(options: RecaptchaProviderOptions) {
super(options);
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
}
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
if (data.score !== undefined && data.score < this.minimumScore) {
this.logger?.warn(
{score: data.score, minimumScore: this.minimumScore},
'reCAPTCHA score below minimum threshold',
);
return false;
}
return true;
}
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class TestCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'test';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class TurnstileProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'turnstile';
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
protected readonly providerName = 'Turnstile';
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class UnavailableCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'unavailable';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -85,7 +85,7 @@ export function createCassandraIpInfoRequestAuditLogger(
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.riskNote,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
+1 -113
View File
@@ -2,7 +2,7 @@
import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting';
import {getSameIpDecisionKey, isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
import maxmind, {type AsnResponse, type CityResponse, type Reader} from 'maxmind';
import maxmind, {type CityResponse, type Reader} from 'maxmind';
export interface GeoipResult {
countryCode: string | null;
@@ -17,33 +17,17 @@ export interface GeoipResult {
timeZone?: string | null;
}
export interface GeoipAsnResult {
normalizedIp: string | null;
asn: number | null;
asnOrg: string | null;
available: boolean;
}
type CacheEntry = {
result: GeoipResult;
expiresAt: number;
};
type AsnCacheEntry = {
result: GeoipAsnResult;
expiresAt: number;
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
const asnCache = new Map<string, AsnCacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
let maxmindAsnReader: Reader<AsnResponse> | null = null;
let maxmindAsnReaderPromise: Promise<Reader<AsnResponse>> | null = null;
let maxmindAsnUnavailable = false;
function buildFallbackResult(normalizedIp: string): GeoipResult {
return {
@@ -60,15 +44,6 @@ function buildFallbackResult(normalizedIp: string): GeoipResult {
};
}
function buildAsnFallbackResult(normalizedIp: string | null): GeoipAsnResult {
return {
normalizedIp: normalizedIp || null,
asn: null,
asnOrg: null,
available: false,
};
}
async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
@@ -86,24 +61,6 @@ async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
return maxmindReaderPromise;
}
async function ensureAsnReader(dbPath: string): Promise<Reader<AsnResponse>> {
if (maxmindAsnReader) return maxmindAsnReader;
if (!maxmindAsnReaderPromise) {
maxmindAsnReaderPromise = maxmind
.open<AsnResponse>(dbPath, {watchForUpdates: true, watchForUpdatesNonPersistent: true})
.then((reader) => {
maxmindAsnReader = reader;
return reader;
})
.catch((error) => {
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = true;
throw error;
});
}
return maxmindAsnReaderPromise;
}
function stateLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
@@ -157,31 +114,6 @@ function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
function getCachedAsnResult(cacheKey: string, normalizedIp: string): GeoipAsnResult | null {
const cached = asnCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
asnCache.delete(cacheKey);
return null;
}
asnCache.delete(cacheKey);
asnCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedAsnResult(cacheKey: string, result: GeoipAsnResult): void {
asnCache.delete(cacheKey);
if (asnCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = asnCache.keys().next().value;
if (oldestKey !== undefined) {
asnCache.delete(oldestKey);
}
}
asnCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -206,24 +138,6 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
}
}
async function lookupMaxmindAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
try {
const reader = await ensureAsnReader(dbPath);
const record = reader.get(clean);
if (!record) {
return {normalizedIp: clean, asn: null, asnOrg: null, available: true};
}
return {
normalizedIp: clean,
asn: record.autonomous_system_number ?? null,
asnOrg: record.autonomous_system_organization ?? null,
available: true,
};
} catch {
return buildAsnFallbackResult(clean);
}
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedGeoipResult(cacheKey, clean);
@@ -235,17 +149,6 @@ async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult>
return result;
}
async function resolveAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedAsnResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmindAsn(clean, dbPath);
setCachedAsnResult(cacheKey, result);
return result;
}
export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): Promise<GeoipResult> {
if (!dbPath) {
return buildFallbackResult(ip);
@@ -257,25 +160,10 @@ export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): P
return resolveGeoip(clean, dbPath);
}
export async function lookupAsnByIp(ip: string, asnDbPath: string | undefined): Promise<GeoipAsnResult> {
if (!asnDbPath || maxmindAsnUnavailable) {
return buildAsnFallbackResult(null);
}
const clean = normalizeIpString(ip);
if (!isValidIp(clean)) {
return buildAsnFallbackResult(clean);
}
return resolveAsn(clean, asnDbPath);
}
export function resetGeoipReadersForTesting(): void {
maxmindReader = null;
maxmindReaderPromise = null;
maxmindAsnReader = null;
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = false;
geoipCache.clear();
asnCache.clear();
}
export function formatGeoipLocation(result: GeoipResult, locale?: string | null): string | null {
+3 -49
View File
@@ -9,26 +9,20 @@ import {pipeline} from 'node:stream/promises';
import {GetObjectCommand, S3Client} from '@aws-sdk/client-s3';
const GEOIP_DOWNLOAD_PATH_QUERY_PARAM = 'download_path';
const GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM = 'asn_download_path';
const GEOIP_ASN_KEY_QUERY_PARAM = 'asn_key';
const DEFAULT_GEOIP_TEMPORARY_DIRECTORY = '/tmp/fluxer/geoip';
const DEFAULT_GEOIP_ASN_DB_BASENAME = 'GeoLite2-ASN.mmdb';
type GeoipSourceMode = 'filesystem' | 's3';
interface GeoipFilesystemSourceConfig {
mode: 'filesystem';
maxmindDbPath?: string;
maxmindAsnDbPath?: string;
}
interface GeoipS3SourceConfig {
mode: 's3';
maxmindDbPath: string;
maxmindAsnDbPath?: string;
s3Bucket: string;
s3Key: string;
s3AsnKey?: string;
}
type GeoipSourceConfig = GeoipFilesystemSourceConfig | GeoipS3SourceConfig;
@@ -50,7 +44,6 @@ interface GeoipStartupResult {
mode: GeoipSourceMode;
downloaded: boolean;
city?: GeoipDownloadedDatabase;
asn?: GeoipDownloadedDatabase;
maxmindDbPath?: string;
bucket?: string;
key?: string;
@@ -83,14 +76,9 @@ export function resolveGeoipRuntimeSourceConfig(
const temporaryDirectory = options.temporaryDirectory ?? DEFAULT_GEOIP_TEMPORARY_DIRECTORY;
requireGeoipRuntimePathOptions(options.serviceName, temporaryDirectory);
const serviceDir = path.join(temporaryDirectory, options.serviceName);
const resolvedCityPath = path.join(serviceDir, path.basename(sourceConfig.maxmindDbPath));
const resolvedAsnPath = sourceConfig.s3AsnKey
? path.join(serviceDir, path.basename(sourceConfig.maxmindAsnDbPath ?? sourceConfig.s3AsnKey))
: sourceConfig.maxmindAsnDbPath;
return {
...sourceConfig,
maxmindDbPath: resolvedCityPath,
maxmindAsnDbPath: resolvedAsnPath,
maxmindDbPath: path.join(serviceDir, path.basename(sourceConfig.maxmindDbPath)),
};
}
@@ -112,9 +100,6 @@ async function ensureS3Startup(
): Promise<GeoipStartupResult> {
const resolvedS3Config = requireGeoipS3ConnectionConfig(s3Config);
await fs.mkdir(path.dirname(geoip.maxmindDbPath), {recursive: true});
if (geoip.maxmindAsnDbPath) {
await fs.mkdir(path.dirname(geoip.maxmindAsnDbPath), {recursive: true});
}
const client = new S3Client({
endpoint: resolvedS3Config.endpoint,
region: resolvedS3Config.region,
@@ -128,15 +113,10 @@ async function ensureS3Startup(
});
try {
const city = await downloadS3Object(client, geoip.s3Bucket, geoip.s3Key, geoip.maxmindDbPath);
let asn: GeoipDownloadedDatabase | undefined;
if (geoip.s3AsnKey && geoip.maxmindAsnDbPath) {
asn = await downloadS3Object(client, geoip.s3Bucket, geoip.s3AsnKey, geoip.maxmindAsnDbPath);
}
return {
mode: 's3',
downloaded: true,
city,
asn,
maxmindDbPath: geoip.maxmindDbPath,
bucket: geoip.s3Bucket,
key: geoip.s3Key,
@@ -168,11 +148,9 @@ async function downloadS3Object(
}
function createGeoipFilesystemSourceConfig(rawValue: string | undefined): GeoipFilesystemSourceConfig {
const maxmindDbPath = rawValue === '' ? undefined : rawValue;
return {
mode: 'filesystem',
maxmindDbPath,
maxmindAsnDbPath: maxmindDbPath ? path.join(path.dirname(maxmindDbPath), DEFAULT_GEOIP_ASN_DB_BASENAME) : undefined,
maxmindDbPath: rawValue === '' ? undefined : rawValue,
};
}
@@ -186,15 +164,11 @@ function parseGeoipS3SourceConfig(rawValue: string): GeoipS3SourceConfig {
if (!s3Key) {
throw new Error(`Invalid GeoIP S3 URL (missing object key): ${rawValue}`);
}
const maxmindDbPath = resolveGeoipDownloadPath(sourceUrl, rawValue);
const {s3AsnKey, maxmindAsnDbPath} = resolveGeoipAsnPaths(sourceUrl, maxmindDbPath, rawValue);
return {
mode: 's3',
maxmindDbPath,
maxmindAsnDbPath,
maxmindDbPath: resolveGeoipDownloadPath(sourceUrl, rawValue),
s3Bucket,
s3Key,
s3AsnKey,
};
}
@@ -211,26 +185,6 @@ function resolveGeoipDownloadPath(sourceUrl: URL, rawValue: string): string {
return configuredDownloadPath;
}
function resolveGeoipAsnPaths(
sourceUrl: URL,
cityDownloadPath: string,
rawValue: string,
): {
s3AsnKey?: string;
maxmindAsnDbPath?: string;
} {
const asnKey = sourceUrl.searchParams.get(GEOIP_ASN_KEY_QUERY_PARAM) ?? undefined;
if (!asnKey) return {};
const explicitAsnDownloadPath = sourceUrl.searchParams.get(GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM);
if (explicitAsnDownloadPath && !path.isAbsolute(explicitAsnDownloadPath)) {
throw new Error(
`GeoIP S3 URL query parameter "${GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM}" must be an absolute path: ${rawValue}`,
);
}
const maxmindAsnDbPath = explicitAsnDownloadPath ?? path.join(path.dirname(cityDownloadPath), path.basename(asnKey));
return {s3AsnKey: asnKey, maxmindAsnDbPath};
}
function requireGeoipS3ConnectionConfig(s3Config: GeoipS3ConnectionConfig | undefined): GeoipS3ConnectionConfig {
if (!s3Config) {
throw new Error('GeoIP is configured for S3 mode, but S3 configuration is missing.');
+14 -41
View File
@@ -13,7 +13,6 @@ const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
const FAILURE_TTL_BACKGROUND_CAP_SECONDS = 120;
export interface IpInfoGeoBlock {
countryCode: string | null;
@@ -65,7 +64,7 @@ export interface IpInfoFlags {
export interface IpInfoLookupResult {
ip: string;
available: boolean;
riskNote: string;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
@@ -78,12 +77,6 @@ export interface IpInfoCache {
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export type IpInfoLookupPriority = 'critical' | 'standard' | 'background';
export interface IpInfoLookupBudget {
tryConsume(priority: IpInfoLookupPriority): Promise<boolean>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
@@ -91,26 +84,15 @@ export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedAtMs: number;
}
export function resolveIpInfoLookupPriority(source: string | undefined): IpInfoLookupPriority {
if (source === 'admin.ip_ban' || source === 'admin.scheduled_deletion_suspicious_ip') return 'critical';
if (source === 'AbusiveIpAutoBanner') return 'background';
return 'standard';
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(
outcome: CachedIpInfoFailure['failureOutcome'],
httpStatus: number | null,
priority: IpInfoLookupPriority,
): number {
let ttl = FAILURE_TTL_HTTP_ERROR_SECONDS;
if (outcome === 'request_failed') ttl = FAILURE_TTL_REQUEST_FAILED_SECONDS;
else if (outcome === 'schema_mismatch') ttl = FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
else if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) ttl = FAILURE_TTL_QUOTA_SECONDS;
return priority === 'background' ? Math.min(ttl, FAILURE_TTL_BACKGROUND_CAP_SECONDS) : ttl;
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
@@ -126,10 +108,10 @@ export interface IpInfoRequestAuditEvent {
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch' | 'budget_shed';
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
riskNote: string;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
@@ -150,7 +132,6 @@ interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
budget?: IpInfoLookupBudget;
}
export interface IpInfoService {
@@ -218,11 +199,10 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const priority = resolveIpInfoLookupPriority(context?.source);
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.riskNote);
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
@@ -251,7 +231,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
riskNote: params.result.riskNote,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
@@ -267,13 +247,6 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
if (ctx.budget && !(await ctx.budget.tryConsume(priority))) {
return finalize({
result: unavailable(ip, `IPInfo lookup shed (budget exhausted, priority: ${priority})`),
outcome: 'budget_shed',
httpStatus: null,
});
}
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
@@ -287,7 +260,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus, priority))
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
@@ -361,7 +334,7 @@ function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
riskNote: reason,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
@@ -425,7 +398,7 @@ function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
return {
ip: raw.ip,
available: true,
riskNote: buildRiskNote(isAnonymous, anon),
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
@@ -486,7 +459,7 @@ function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
};
}
function buildRiskNote(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
@@ -118,7 +118,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.riskNote,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
@@ -74,6 +74,8 @@ export interface IKVProvider {
rpush(key: string, ...values: Array<string>): Promise<number>;
lpop(key: string, count?: number): Promise<Array<string>>;
llen(key: string): Promise<number>;
lrange(key: string, start: number, stop: number): Promise<Array<string>>;
ltrim(key: string, start: number, stop: number): Promise<void>;
hset(key: string, field: string, value: string): Promise<number>;
hdel(key: string, ...fields: Array<string>): Promise<number>;
hget(key: string, field: string): Promise<string | null>;
@@ -555,6 +555,14 @@ export class KVClient implements IKVProvider {
return await this.execute('llen', async () => this.client.llen(key));
}
async lrange(key: string, start: number, stop: number): Promise<Array<string>> {
return await this.execute('lrange', async () => this.client.lrange(key, start, stop));
}
async ltrim(key: string, start: number, stop: number): Promise<void> {
await this.execute('ltrim', async () => this.client.ltrim(key, start, stop));
}
async hset(key: string, field: string, value: string): Promise<number> {
return await this.execute('hset', async () => this.client.hset(key, field, value));
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@pkgs/sms",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"@fluxer/errors": "workspace:*",
"@fluxer/logger": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
-14
View File
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export interface ISmsService {
startVerification(phone: string): Promise<void>;
startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult>;
checkVerification(phone: string, code: string): Promise<boolean>;
lookupPhone(phone: string): Promise<PhoneLookupResult | null>;
}
@@ -1,64 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type PhoneLineType =
| 'mobile'
| 'landline'
| 'fixedVoip'
| 'nonFixedVoip'
| 'personal'
| 'tollFree'
| 'premium'
| 'sharedCost'
| 'uan'
| 'voicemail'
| 'pager'
| 'unknown';
export interface PhoneLookupResult {
valid: boolean;
lineType: PhoneLineType | null;
countryCode: string | null;
carrierName: string | null;
smsPumpingRiskScore: number | null;
}
export const ACCEPTED_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>(['mobile', 'personal']);
export const VOIP_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>(['fixedVoip', 'nonFixedVoip']);
export const HARD_REJECT_PHONE_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
'landline',
'tollFree',
'premium',
'sharedCost',
'uan',
'voicemail',
'pager',
]);
const SMS_PUMPING_RISK_THRESHOLDS: Readonly<Record<string, number>> = {
US: 100,
CA: 100,
GB: 70,
DE: 70,
FR: 70,
IT: 70,
ES: 70,
NL: 70,
SE: 70,
NO: 70,
DK: 70,
FI: 70,
AU: 70,
NZ: 70,
JP: 70,
KR: 70,
CH: 70,
AT: 70,
BE: 70,
IE: 70,
PT: 70,
};
const DEFAULT_SMS_PUMPING_RISK_THRESHOLD = 35;
export function getSmsPumpingRiskThreshold(countryCode: string | null): number {
if (countryCode === null) return DEFAULT_SMS_PUMPING_RISK_THRESHOLD;
return SMS_PUMPING_RISK_THRESHOLDS[countryCode] ?? DEFAULT_SMS_PUMPING_RISK_THRESHOLD;
}
-34
View File
@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ISmsService} from '@pkgs/sms/src/ISmsService';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export class SmsService implements ISmsService {
private readonly provider: ISmsProvider;
constructor(provider: ISmsProvider = new UnavailableSmsProvider()) {
this.provider = provider;
}
async startVerification(phone: string): Promise<void> {
await this.provider.startVerification(phone);
}
async startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult> {
return this.provider.startVerificationWithResult(phone, options);
}
async checkVerification(phone: string, code: string): Promise<boolean> {
return this.provider.checkVerification(phone, code);
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
return this.provider.lookupPhone(phone);
}
}
@@ -1,17 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type SmsVerificationStartChannel = 'sms' | 'auto';
export interface SmsVerificationStartOptions {
channel?: SmsVerificationStartChannel;
deviceIp?: string;
rateLimits?: Record<string, string>;
}
export interface SmsVerificationStartResult {
channel: string;
}
export const SMS_VERIFICATION_START_SMS_RESULT: SmsVerificationStartResult = {
channel: 'sms',
};
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SMS_MASK_VISIBLE_PREFIX_LENGTH} from '@fluxer/constants/src/SmsVerificationConstants';
export function maskPhoneNumber(phone: string): string {
if (phone.length <= SMS_MASK_VISIBLE_PREFIX_LENGTH) {
return `${phone}***`;
}
return `${phone.slice(0, SMS_MASK_VISIBLE_PREFIX_LENGTH)}***`;
}
@@ -1,100 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
import {SmsService} from '@pkgs/sms/src/SmsService';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {describe, expect, it} from 'vitest';
function createInMemoryProvider(): ISmsProvider & {
verifications: Map<string, string>;
startedVerifications: Array<string>;
} {
const verifications = new Map<string, string>();
const startedVerifications: Array<string> = [];
return {
verifications,
startedVerifications,
async startVerification(phone: string): Promise<void> {
startedVerifications.push(phone);
verifications.set(phone, '123456');
},
async startVerificationWithResult(phone: string): Promise<SmsVerificationStartResult> {
startedVerifications.push(phone);
verifications.set(phone, '123456');
return SMS_VERIFICATION_START_SMS_RESULT;
},
async checkVerification(phone: string, code: string): Promise<boolean> {
const storedCode = verifications.get(phone);
if (storedCode === code) {
verifications.delete(phone);
return true;
}
return false;
},
async lookupPhone(_phone: string): Promise<PhoneLookupResult | null> {
return null;
},
};
}
describe('SmsService', () => {
describe('with provider', () => {
it('starts verification through provider', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
expect(provider.startedVerifications).toContain('+15551234567');
expect(provider.verifications.has('+15551234567')).toBe(true);
});
it('checks verification through provider and returns true for valid code', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
const code = provider.verifications.get('+15551234567') ?? '';
const result = await service.checkVerification('+15551234567', code);
expect(result).toBe(true);
});
it('checks verification through provider and returns false for invalid code', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
await service.startVerification('+15551234567');
const result = await service.checkVerification('+15551234567', 'wrong-code');
expect(result).toBe(false);
});
it('returns false for verification check on non-existent phone', async () => {
const provider = createInMemoryProvider();
const service = new SmsService(provider);
const result = await service.checkVerification('+15559999999', '123456');
expect(result).toBe(false);
});
});
describe('with unavailable provider', () => {
it('silently completes startVerification when provider is unavailable', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.startVerification('+15551234567')).resolves.toBeUndefined();
});
it('throws SmsVerificationUnavailableError when checking verification', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.checkVerification('+15551234567', '123456')).rejects.toThrow(
SmsVerificationUnavailableError,
);
});
it('defaults to unavailable provider when no provider is injected', async () => {
const service = new SmsService();
await expect(service.checkVerification('+15551234567', '123456')).rejects.toThrow(
SmsVerificationUnavailableError,
);
});
it('exposes the correct api error code when checking verification', async () => {
const service = new SmsService(new UnavailableSmsProvider());
await expect(service.checkVerification('+15551234567', '123456')).rejects.toMatchObject({
code: APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE,
message: APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE,
});
});
});
});
@@ -1,369 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {createMockLogger} from '@fluxer/logger/src/mock';
import {TwilioSmsProvider, TwilioVerificationRateLimitError} from '@pkgs/sms/src/providers/TwilioSmsProvider';
import {describe, expect, it} from 'vitest';
interface TwilioRequest {
url: string;
authHeader: string;
body: string;
}
interface TwilioLookupRequest {
url: string;
method: string | undefined;
authHeader: string;
}
function getCapturedRequest(request: TwilioRequest | null): TwilioRequest {
if (!request) {
throw new Error('Expected Twilio request to be captured');
}
return request;
}
function getCapturedLookupRequest(request: TwilioLookupRequest | null): TwilioLookupRequest {
if (!request) {
throw new Error('Expected Twilio lookup request to be captured');
}
return request;
}
describe('TwilioSmsProvider', () => {
it('calls Twilio Verify start endpoint with expected payload', async () => {
let capturedRequest: TwilioRequest | null = null;
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({success: true}), {status: 200});
};
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: fetchStub,
});
const phone = '+15551234567';
await provider.startVerification(phone);
const request = getCapturedRequest(capturedRequest);
expect(request.url).toBe('https://verify.twilio.com/v2/Services/VA123/Verifications');
expect(request.authHeader).toBe(`Basic ${Buffer.from('AC123:twilio-secret').toString('base64')}`);
expect(request.body).toContain('To=%2B15551234567');
expect(request.body).toContain('Channel=sms');
});
it('can start verification with auto channel, device IP, and programmable rate limits', async () => {
let capturedRequest: TwilioRequest | null = null;
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({channel: 'auto'}), {status: 200});
};
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: fetchStub,
});
const result = await provider.startVerificationWithResult('+15551234567', {
channel: 'auto',
deviceIp: '203.0.113.10',
rateLimits: {
fluxer_user_id: '123',
fluxer_phone_prefix: '+1555',
},
});
const request = getCapturedRequest(capturedRequest);
expect(result).toEqual({channel: 'auto'});
expect(request.body).toContain('Channel=auto');
expect(request.body).toContain('DeviceIp=203.0.113.10');
expect(request.body).toContain('RateLimits%5Bfluxer_user_id%5D=123');
expect(request.body).toContain('RateLimits%5Bfluxer_phone_prefix%5D=%2B1555');
});
it('returns true when verification check is approved', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({status: 'approved'}), {status: 200}),
});
const result = await provider.checkVerification('+15551234567', '123456');
expect(result).toBe(true);
});
it('returns false when verification check is rejected', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({status: 'pending'}), {status: 200}),
});
expect(await provider.checkVerification('+15551234567', '123456')).toBe(false);
});
it('throws InvalidPhoneNumberError for Twilio invalid phone code', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 21211, message: 'Invalid To phone number'}), {status: 400}),
});
await expect(provider.startVerification('+15550000000')).rejects.toThrow(InvalidPhoneNumberError);
});
it('throws TwilioVerificationRateLimitError for Twilio max-send-attempt responses', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60203, message: 'Max send attempts reached'}), {status: 403}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Too many verification texts were sent recently. Try again later.',
twilioStatus: 403,
twilioCode: 60203,
cooldownScope: 'phone',
cooldownMs: 600000,
});
});
it('throws TwilioVerificationRateLimitError for Fraud Guard blocks', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60410, message: 'Blocked by Verify Fraud Guard'}), {status: 403}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Phone verification is temporarily blocked for this destination. Try again later.',
twilioStatus: 403,
twilioCode: 60410,
cooldownScope: 'phone',
cooldownMs: 43200000,
});
});
it('throws SmsVerificationUnavailableError for unexpected non-OK start responses', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({code: 30001, message: 'Queue overflow'}), {status: 503}),
});
const error = await provider.startVerification('+15551234567').catch((err: unknown) => err);
expect(error).toBeInstanceOf(SmsVerificationUnavailableError);
});
it('throws SmsVerificationUnavailableError when start verification request fails before a response', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => {
throw new TypeError('Failed to fetch');
},
});
await expect(provider.startVerification('+15551234567')).rejects.toThrow(SmsVerificationUnavailableError);
});
it('throws TwilioVerificationRateLimitError for max verification-check attempts', async () => {
const provider = new TwilioSmsProvider({
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 60202, message: 'Max check attempts reached'}), {status: 429}),
});
const error = await provider.checkVerification('+15551234567', '123456').catch((err: unknown) => err);
expect(error).toBeInstanceOf(TwilioVerificationRateLimitError);
expect(error).toMatchObject({
message: 'Too many verification code checks were attempted. Request a new code later.',
twilioStatus: 429,
twilioCode: 60202,
cooldownScope: 'phone',
});
});
describe('lookupPhone', () => {
const CONFIG = {accountSid: 'AC123', authToken: 'twilio-secret', verifyServiceSid: 'VA123'};
it('sends a GET to Lookup v2 with Fields=line_type_intelligence and Basic auth', async () => {
let capturedRequest: TwilioLookupRequest | null = null;
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async (input, init) => {
capturedRequest = {
url: String(input),
method: init?.method,
authHeader: (init?.headers as Record<string, string>)?.Authorization,
};
return new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'mobile', carrier_name: 'T-Mobile USA'},
}),
{status: 200},
);
},
});
const result = await provider.lookupPhone('+15551234567');
const request = getCapturedLookupRequest(capturedRequest);
expect(request.method).toBe('GET');
expect(request.url).toBe(
'https://lookups.twilio.com/v2/PhoneNumbers/%2B15551234567?Fields=line_type_intelligence,sms_pumping_risk',
);
expect(request.authHeader).toBe(`Basic ${Buffer.from('AC123:twilio-secret').toString('base64')}`);
expect(result).toEqual({
valid: true,
lineType: 'mobile',
countryCode: 'US',
carrierName: 'T-Mobile USA',
smsPumpingRiskScore: null,
});
});
it('returns nonFixedVoip for Twilio virtual-number responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'NL',
line_type_intelligence: {type: 'nonFixedVoip', carrier_name: 'Twilio LLC'},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+3197058046509');
expect(result?.valid).toBe(true);
expect(result?.lineType).toBe('nonFixedVoip');
});
it('returns personal for +31970-style Dutch personal-number responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'NL',
line_type_intelligence: {type: 'personal', carrier_name: null},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+31970123456');
expect(result?.lineType).toBe('personal');
});
it('returns valid=false on 404 (phone not found)', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () => new Response('', {status: 404}),
});
const result = await provider.lookupPhone('+15550000000');
expect(result).toEqual({
valid: false,
lineType: null,
countryCode: null,
carrierName: null,
smsPumpingRiskScore: null,
});
});
it('returns null (fail-closed trigger) on non-OK non-404 responses', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(JSON.stringify({code: 20003, message: 'Authentication error'}), {status: 401}),
});
const result = await provider.lookupPhone('+15551234567');
expect(result).toBeNull();
});
it('returns null on network failure', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () => {
throw new TypeError('Failed to fetch');
},
});
const result = await provider.lookupPhone('+15551234567');
expect(result).toBeNull();
});
it('normalizes unknown Twilio line-type values to "unknown"', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'someBrandNewTwilioType', carrier_name: 'Test'},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+15551234567');
expect(result?.lineType).toBe('unknown');
});
it('parses sms_pumping_risk_score when present', async () => {
const provider = new TwilioSmsProvider({
config: CONFIG,
logger: createMockLogger(),
fetchFn: async () =>
new Response(
JSON.stringify({
valid: true,
country_code: 'US',
line_type_intelligence: {type: 'mobile', carrier_name: 'T-Mobile USA'},
sms_pumping_risk: {sms_pumping_risk_score: 72},
}),
{status: 200},
),
});
const result = await provider.lookupPhone('+15551234567');
expect(result?.smsPumpingRiskScore).toBe(72);
});
});
});
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export interface ISmsProvider {
startVerification(phone: string): Promise<void>;
startVerificationWithResult(
phone: string,
options?: SmsVerificationStartOptions,
): Promise<SmsVerificationStartResult>;
checkVerification(phone: string, code: string): Promise<boolean>;
lookupPhone(phone: string): Promise<PhoneLookupResult | null>;
}
@@ -1,48 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {TestSmsProvider} from '@pkgs/sms/src/providers/TestSmsProvider';
import {TwilioSmsProvider, type TwilioSmsProviderConfig} from '@pkgs/sms/src/providers/TwilioSmsProvider';
import {UnavailableSmsProvider} from '@pkgs/sms/src/providers/UnavailableSmsProvider';
interface BaseSmsProviderFactoryParams {
logger?: LoggerInterface;
}
interface CreateUnavailableSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'unavailable';
}
interface CreateTestSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'test';
verificationCode?: string;
}
interface CreateTwilioSmsProviderParams extends BaseSmsProviderFactoryParams {
mode: 'twilio';
config: TwilioSmsProviderConfig;
fetchFn?: typeof fetch;
}
type CreateSmsProviderParams =
| CreateUnavailableSmsProviderParams
| CreateTestSmsProviderParams
| CreateTwilioSmsProviderParams;
export function createSmsProvider(params: CreateSmsProviderParams): ISmsProvider {
if (params.mode === 'test') {
return new TestSmsProvider({
logger: params.logger,
verificationCode: params.verificationCode,
});
}
if (params.mode === 'twilio') {
return new TwilioSmsProvider({
config: params.config,
logger: params.logger,
fetchFn: params.fetchFn,
});
}
return new UnavailableSmsProvider();
}
@@ -1,54 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SMS_TEST_VERIFICATION_CODE} from '@fluxer/constants/src/SmsVerificationConstants';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {maskPhoneNumber} from '@pkgs/sms/src/SmsVerificationUtils';
interface TestSmsProviderOptions {
logger?: LoggerInterface;
verificationCode?: string;
}
export class TestSmsProvider implements ISmsProvider {
private readonly logger: LoggerInterface;
private readonly verificationCode: string;
constructor({logger, verificationCode}: TestSmsProviderOptions = {}) {
this.logger = logger ?? createLogger('@pkgs/sms/src', {environment: 'test'});
this.verificationCode = verificationCode ?? SMS_TEST_VERIFICATION_CODE;
}
async startVerification(phone: string): Promise<void> {
this.logger.info(
`[TestSmsProvider] Mock verification started for ${maskPhoneNumber(phone)}. Use code: ${this.verificationCode}`,
);
}
async startVerificationWithResult(phone: string): Promise<SmsVerificationStartResult> {
await this.startVerification(phone);
return SMS_VERIFICATION_START_SMS_RESULT;
}
async checkVerification(phone: string, code: string): Promise<boolean> {
const isValid = code === this.verificationCode;
this.logger.info(
`[TestSmsProvider] Mock verification check for ${maskPhoneNumber(phone)} with code ${code}: ${isValid ? 'APPROVED' : 'REJECTED'}`,
);
return isValid;
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
this.logger.info(`[TestSmsProvider] Mock lookup for ${maskPhoneNumber(phone)} -> valid mobile`);
return {
valid: true,
lineType: 'mobile',
countryCode: null,
carrierName: 'Test Carrier',
smsPumpingRiskScore: null,
};
}
}
@@ -1,512 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
SMS_TWILIO_DEFAULT_LOOKUP_API_URL,
SMS_TWILIO_DEFAULT_VERIFY_API_URL,
} from '@fluxer/constants/src/SmsVerificationConstants';
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {PhoneLineType, PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import type {SmsVerificationStartOptions, SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
import {maskPhoneNumber} from '@pkgs/sms/src/SmsVerificationUtils';
const TWILIO_INVALID_PHONE_ERROR_CODE = 21211;
const TWILIO_TOO_MANY_REQUESTS_ERROR_CODE = 20429;
const TWILIO_MAX_SEND_ATTEMPTS_ERROR_CODE = 60203;
const TWILIO_MAX_CHECK_ATTEMPTS_ERROR_CODE = 60202;
const TWILIO_CONCURRENT_REQUESTS_ERROR_CODE = 60212;
const TWILIO_FRAUD_GUARD_BLOCK_ERROR_CODE = 60410;
const TWILIO_FRAUD_PREVENTION_BLOCK_ERROR_CODE = 60412;
const TWILIO_DEFAULT_BUSY_RETRY_AFTER_SECONDS = 60;
const TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS = 10 * 60;
const TWILIO_FRAUD_BLOCK_RETRY_AFTER_SECONDS = 12 * 60 * 60;
interface TwilioErrorResponse {
code?: number;
message?: string;
}
interface TwilioResponse {
ok: boolean;
status: number;
body: unknown;
}
type TwilioCooldownScope = 'account' | 'phone' | 'account_and_phone';
interface StartVerificationSentryContext extends Record<string, unknown> {
smsProvider: 'twilio';
smsOperation: 'start_verification';
twilioEndpoint: 'Verifications';
phone: string;
twilioStatus?: number;
twilioCode?: number;
twilioMessage?: string;
twilioRequestError?: string;
}
interface TwilioLookupV2Response {
valid: boolean;
country_code?: string | null;
line_type_intelligence?: {
type?: string | null;
carrier_name?: string | null;
error_code?: number | null;
} | null;
sms_pumping_risk?: {
sms_pumping_risk_score?: number | null;
error_code?: number | null;
} | null;
}
export interface TwilioSmsProviderConfig {
accountSid: string;
authToken: string;
verifyServiceSid: string;
verifyApiUrl?: string;
lookupApiUrl?: string;
lookupTimeoutMs?: number;
}
interface TwilioSmsProviderDependencies {
config: TwilioSmsProviderConfig;
logger?: LoggerInterface;
fetchFn?: typeof fetch;
}
const DEFAULT_LOOKUP_TIMEOUT_MS = 3000;
const VERIFY_TIMEOUT_MS = 10000;
const KNOWN_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
'mobile',
'landline',
'fixedVoip',
'nonFixedVoip',
'personal',
'tollFree',
'premium',
'sharedCost',
'uan',
'voicemail',
'pager',
'unknown',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNullishString(value: unknown): value is string | null | undefined {
return value == null || typeof value === 'string';
}
function isNullishInteger(value: unknown): value is number | null | undefined {
return value == null || (typeof value === 'number' && Number.isSafeInteger(value));
}
function isTwilioLookupResponse(value: unknown): value is TwilioLookupV2Response {
if (!isRecord(value) || typeof value.valid !== 'boolean' || !isNullishString(value.country_code)) return false;
const line = value.line_type_intelligence;
if (
line != null &&
(!isRecord(line) ||
!isNullishString(line.type) ||
!isNullishString(line.carrier_name) ||
!isNullishInteger(line.error_code))
) {
return false;
}
const risk = value.sms_pumping_risk;
if (risk == null) return true;
if (!isRecord(risk) || !isNullishInteger(risk.error_code)) return false;
const score = risk.sms_pumping_risk_score;
return isNullishInteger(score) && (score == null || (score >= 0 && score <= 100));
}
function parseTwilioError(value: unknown): TwilioErrorResponse | null {
if (!isRecord(value)) return null;
if (value.code !== undefined && (typeof value.code !== 'number' || !Number.isSafeInteger(value.code))) return null;
if (value.message !== undefined && typeof value.message !== 'string') return null;
return {code: value.code, message: value.message};
}
function normalizeLineType(raw: string | null | undefined): PhoneLineType | null {
if (!raw) return null;
return KNOWN_LINE_TYPES.has(raw as PhoneLineType) ? (raw as PhoneLineType) : 'unknown';
}
export class SmsVerificationStartError extends Error {
readonly sentryContext: StartVerificationSentryContext;
constructor(sentryContext: StartVerificationSentryContext, options?: ErrorOptions) {
super('Failed to start SMS verification', options);
this.name = 'SmsVerificationStartError';
this.sentryContext = sentryContext;
}
}
export class TwilioVerificationRateLimitError extends RateLimitError {
readonly twilioCode?: number;
readonly twilioStatus?: number;
readonly cooldownScope: TwilioCooldownScope;
readonly cooldownMs: number;
constructor(args: {
message: string;
retryAfterSeconds: number;
twilioCode?: number;
twilioStatus?: number;
cooldownScope: TwilioCooldownScope;
cooldownMs?: number;
scope?: 'shared' | 'user';
}) {
const retryAfterSeconds = Math.max(1, Math.ceil(args.retryAfterSeconds));
super({
code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED,
message: args.message,
retryAfter: retryAfterSeconds,
retryAfterDecimal: retryAfterSeconds,
limit: 1,
resetTime: new Date(Date.now() + retryAfterSeconds * 1000),
resetAfterDecimal: retryAfterSeconds,
scope: args.scope ?? 'shared',
});
this.name = 'TwilioVerificationRateLimitError';
this.twilioCode = args.twilioCode;
this.twilioStatus = args.twilioStatus;
this.cooldownScope = args.cooldownScope;
this.cooldownMs = args.cooldownMs ?? retryAfterSeconds * 1000;
}
}
export class TwilioSmsProvider implements ISmsProvider {
private readonly verifyApiUrl: string;
private readonly lookupApiUrl: string;
private readonly lookupTimeoutMs: number;
private readonly logger: LoggerInterface;
private readonly config: TwilioSmsProviderConfig;
private readonly fetchFn: typeof fetch;
constructor({config, logger, fetchFn = fetch}: TwilioSmsProviderDependencies) {
this.verifyApiUrl = config.verifyApiUrl ?? SMS_TWILIO_DEFAULT_VERIFY_API_URL;
this.lookupApiUrl = config.lookupApiUrl ?? SMS_TWILIO_DEFAULT_LOOKUP_API_URL;
this.lookupTimeoutMs = config.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
this.logger = logger ?? createLogger('@pkgs/sms/src');
this.config = config;
this.fetchFn = fetchFn;
}
async startVerification(phone: string): Promise<void> {
await this.startVerificationWithResult(phone);
}
async startVerificationWithResult(
phone: string,
options: SmsVerificationStartOptions = {},
): Promise<SmsVerificationStartResult> {
const requestedChannel = options.channel ?? 'sms';
const requestBody: Record<string, string> = {
To: phone,
Channel: requestedChannel,
};
if (options.deviceIp) {
requestBody.DeviceIp = options.deviceIp;
}
if (options.rateLimits) {
for (const [key, value] of Object.entries(options.rateLimits)) {
if (!key || !value) continue;
requestBody[`RateLimits[${key}]`] = value;
}
}
let response: TwilioResponse;
try {
response = await this.requestTwilio('Verifications', requestBody);
} catch (error) {
const sentryContext = this.createStartVerificationSentryContext(phone, {
requestError: error,
});
this.logger.error(sentryContext, '[TwilioSmsProvider] Twilio request failed while starting SMS verification');
throw new SmsVerificationUnavailableError();
}
if (response.ok) {
const parsed = response.body;
if (
!isRecord(parsed) ||
!isNullishString(parsed.channel) ||
(parsed.status !== undefined && parsed.status !== 'pending' && parsed.status !== 'approved')
) {
this.logger.error(
{phone: maskPhoneNumber(phone), status: response.status},
'[TwilioSmsProvider] Invalid verification start response',
);
throw new SmsVerificationUnavailableError();
}
return {
channel: parsed.channel ?? requestedChannel,
};
}
const body = parseTwilioError(response.body);
if (body?.code === TWILIO_INVALID_PHONE_ERROR_CODE) {
throw new InvalidPhoneNumberError();
}
const rateLimitError = this.createRateLimitError(response, body, 'Verifications');
if (rateLimitError) {
throw rateLimitError;
}
const sentryContext = this.createStartVerificationSentryContext(phone, {
response,
body,
});
this.logger.error(sentryContext, '[TwilioSmsProvider] Failed to start SMS verification');
throw new SmsVerificationUnavailableError();
}
async checkVerification(phone: string, code: string): Promise<boolean> {
let response: TwilioResponse;
try {
response = await this.requestTwilio('VerificationCheck', {
To: phone,
Code: code,
});
} catch (error) {
this.logger.error(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Twilio request failed while checking SMS verification',
);
throw new SmsVerificationUnavailableError();
}
if (!response.ok) {
const body = parseTwilioError(response.body);
const rateLimitError = this.createRateLimitError(response, body, 'VerificationCheck');
if (rateLimitError) {
throw rateLimitError;
}
if (response.status >= 500) {
this.logger.error(
{
status: response.status,
code: body?.code,
message: body?.message,
phone: maskPhoneNumber(phone),
},
'[TwilioSmsProvider] Verification check failed with provider error',
);
throw new SmsVerificationUnavailableError();
}
return false;
}
const body = response.body;
if (!isRecord(body) || typeof body.status !== 'string') {
this.logger.error(
{phone: maskPhoneNumber(phone), status: response.status},
'[TwilioSmsProvider] Invalid verification check response',
);
throw new SmsVerificationUnavailableError();
}
return body.status === 'approved';
}
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
const url = `${this.lookupApiUrl}/PhoneNumbers/${encodeURIComponent(phone)}?Fields=line_type_intelligence,sms_pumping_risk`;
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
let response: Response;
try {
response = await this.fetchFn(url, {
method: 'GET',
headers: {Authorization: `Basic ${auth}`},
signal: AbortSignal.timeout(this.lookupTimeoutMs),
});
} catch (error) {
this.logger.warn(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Lookup request failed (fail-open)',
);
return null;
}
if (response.status === 404) {
await response.body?.cancel().catch(() => {
this.logger.warn(
{status: response.status},
'[TwilioSmsProvider] Failed to cancel discarded lookup response body',
);
});
return {
valid: false,
lineType: null,
countryCode: null,
carrierName: null,
smsPumpingRiskScore: null,
};
}
if (!response.ok) {
const body = await this.parseErrorBody(response);
this.logger.warn(
{
status: response.status,
code: body?.code,
message: body?.message,
phone: maskPhoneNumber(phone),
},
'[TwilioSmsProvider] Lookup returned non-OK (fail-open)',
);
return null;
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (error) {
this.logger.warn(
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
'[TwilioSmsProvider] Lookup response JSON parse failed (fail-open)',
);
return null;
}
if (!isTwilioLookupResponse(parsed)) {
this.logger.warn({phone: maskPhoneNumber(phone)}, '[TwilioSmsProvider] Invalid lookup response (fail-open)');
return null;
}
const countryCode = parsed.country_code ?? null;
const carrierName = parsed.line_type_intelligence?.carrier_name ?? null;
const ltiErrorCode = parsed.line_type_intelligence?.error_code ?? null;
const sprErrorCode = parsed.sms_pumping_risk?.error_code ?? null;
if (ltiErrorCode != null) {
this.logger.warn(
{phone: maskPhoneNumber(phone), countryCode, ltiErrorCode},
'[TwilioSmsProvider] Lookup line_type_intelligence reported error_code',
);
}
if (sprErrorCode != null) {
this.logger.warn(
{phone: maskPhoneNumber(phone), countryCode, sprErrorCode},
'[TwilioSmsProvider] Lookup sms_pumping_risk reported error_code',
);
}
return {
valid: parsed.valid,
lineType: normalizeLineType(parsed.line_type_intelligence?.type),
countryCode,
carrierName,
smsPumpingRiskScore: parsed.sms_pumping_risk?.sms_pumping_risk_score ?? null,
};
}
private async requestTwilio(
endpoint: 'Verifications' | 'VerificationCheck',
body: Record<string, string>,
): Promise<TwilioResponse> {
const url = `${this.verifyApiUrl}/Services/${this.config.verifyServiceSid}/${endpoint}`;
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
const response = await this.fetchFn(url, {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
});
const parsed: unknown = await response.json().catch((error: unknown) => {
if (response.ok) throw error;
return null;
});
return {ok: response.ok, status: response.status, body: parsed};
}
private async parseErrorBody(response: Response): Promise<TwilioErrorResponse | null> {
try {
return parseTwilioError(await response.json());
} catch {
return null;
}
}
private createStartVerificationSentryContext(
phone: string,
params: {
response?: Pick<Response, 'status'>;
body?: TwilioErrorResponse | null;
requestError?: unknown;
},
): StartVerificationSentryContext {
const sentryContext: StartVerificationSentryContext = {
smsProvider: 'twilio',
smsOperation: 'start_verification',
twilioEndpoint: 'Verifications',
phone: maskPhoneNumber(phone),
};
if (params.response) {
sentryContext.twilioStatus = params.response.status;
}
if (params.body?.code !== undefined) {
sentryContext.twilioCode = params.body.code;
}
if (params.body?.message !== undefined) {
sentryContext.twilioMessage = params.body.message;
}
if (params.requestError !== undefined) {
sentryContext.twilioRequestError =
params.requestError instanceof Error ? params.requestError.message : String(params.requestError);
}
return sentryContext;
}
private createRateLimitError(
response: Pick<Response, 'status'>,
body: TwilioErrorResponse | null,
endpoint: 'Verifications' | 'VerificationCheck',
): TwilioVerificationRateLimitError | null {
const twilioCode = body?.code;
const twilioStatus = response.status;
if (twilioCode === TWILIO_MAX_SEND_ATTEMPTS_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Too many verification texts were sent recently. Try again later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_CONCURRENT_REQUESTS_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Too many verification requests are already in flight for this number. Try again later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_MAX_CHECK_ATTEMPTS_ERROR_CODE && endpoint === 'VerificationCheck') {
return new TwilioVerificationRateLimitError({
message: 'Too many verification code checks were attempted. Request a new code later.',
retryAfterSeconds: TWILIO_VERIFY_WINDOW_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_FRAUD_GUARD_BLOCK_ERROR_CODE || twilioCode === TWILIO_FRAUD_PREVENTION_BLOCK_ERROR_CODE) {
return new TwilioVerificationRateLimitError({
message: 'Phone verification is temporarily blocked for this destination. Try again later.',
retryAfterSeconds: TWILIO_FRAUD_BLOCK_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'phone',
});
}
if (twilioCode === TWILIO_TOO_MANY_REQUESTS_ERROR_CODE || (twilioCode == null && twilioStatus === 429)) {
return new TwilioVerificationRateLimitError({
message: 'Phone verification is temporarily busy. Try again shortly.',
retryAfterSeconds: TWILIO_DEFAULT_BUSY_RETRY_AFTER_SECONDS,
twilioCode,
twilioStatus,
cooldownScope: 'account',
scope: 'user',
});
}
return null;
}
}
@@ -1,24 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
import type {ISmsProvider} from '@pkgs/sms/src/providers/ISmsProvider';
import {SMS_VERIFICATION_START_SMS_RESULT, type SmsVerificationStartResult} from '@pkgs/sms/src/SmsVerificationTypes';
export class UnavailableSmsProvider implements ISmsProvider {
async startVerification(_phone: string): Promise<void> {
return;
}
async startVerificationWithResult(_phone: string): Promise<SmsVerificationStartResult> {
return SMS_VERIFICATION_START_SMS_RESULT;
}
async checkVerification(_phone: string, _code: string): Promise<boolean> {
throw new SmsVerificationUnavailableError();
}
async lookupPhone(_phone: string): Promise<PhoneLookupResult | null> {
return null;
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
-18
View File
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineConfig} from 'vitest/config';
export default defineConfig({
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
-8
View File
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {InboundSmsChallengeService} from '@app/api/auth/services/InboundSmsChallengeService';
import type {PhoneAttemptRiskService} from '@app/api/auth/services/PhoneAttemptRiskService';
import type {IPhoneLookupRepository} from '@app/api/auth/services/PhoneLookupRepository';
import type {Config} from '@app/api/Config';
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -17,7 +14,6 @@ import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {ISmsService} from '@pkgs/sms/src/ISmsService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
export interface ApiServices {
@@ -28,14 +24,10 @@ export interface ApiServices {
media: IMediaService;
email: IEmailService;
emailDnsValidation: IEmailDnsValidationService;
sms: ISmsService;
worker: IWorkerService<WorkerTaskName>;
snowflake: ISnowflakeService;
rateLimit: IRateLimitService;
contactChangeLog: UserContactChangeLogService;
inboundSmsChallenge: InboundSmsChallengeService | null;
phoneLookup: IPhoneLookupRepository | null;
phoneAttemptRisk: PhoneAttemptRiskService;
botMfaMirror: BotMfaMirrorService;
userActivityBuffer: UserActivityBuffer;
config: typeof Config;
+6 -7
View File
@@ -5,7 +5,7 @@ import {registerControllers} from '@app/api/app/ControllerRegistry';
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {ILogger} from '@app/api/ILogger';
import {recordHttpClientError} from '@app/api/middleware/AbusiveIpAutoBanner';
import {recordRequestStatus} from '@app/api/middleware/RequestErrorTelemetry';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
@@ -27,11 +27,11 @@ interface APIAppResult {
shutdown: () => Promise<void>;
}
function AbuseAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response | Promise<Response> {
function TelemetryAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response | Promise<Response> {
if (!(err instanceof IpBannedError)) {
const status = resolveErrorStatus(err);
if (status !== null && !ctx.get('user')) {
recordHttpClientError(ctx.req.raw, status);
recordRequestStatus(ctx.req.raw, status);
}
}
return AppErrorHandler(err, ctx);
@@ -45,13 +45,12 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
configureMiddleware(routes, {
logger,
nodeEnv: config.nodeEnv,
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
torExitBlockingEnabled: config.torExitList.enabled,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.onError(TelemetryAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
registerControllers(routes, config);
const app = new Hono<HonoEnv>({strict: true});
@@ -67,7 +66,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
);
app.route('/v1', routes);
app.route('/', routes);
app.onError(AbuseAwareAppErrorHandler);
app.onError(TelemetryAwareAppErrorHandler);
app.notFound(AppNotFoundHandler);
return {
app,
-1
View File
@@ -39,7 +39,6 @@ type IpAuthorizationTicket = Brand<string, 'IpAuthorizationTicket'>;
type MfaTicket = Brand<string, 'MfaTicket'>;
export type WebhookToken = Brand<string, 'WebhookToken'>;
export type MfaBackupCode = Brand<string, 'MfaBackupCode'>;
export type PhoneVerificationToken = Brand<string, 'PhoneVerificationToken'>;
export function createUserID<T extends bigint>(id: T extends BrandedValue ? never : T): UserID {
return brand<T, 'UserID'>(id);
+7 -16
View File
@@ -170,13 +170,12 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
function withOptionalOutboundLookups(
master: MasterConfig,
selfHosted: boolean,
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
overrides: {breachedPasswordCheck?: boolean} = {},
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
tor_exit_list: {enabled: overrides.torExitList},
breached_password_check: {enabled: overrides.breachedPasswordCheck},
},
instance: {
@@ -192,31 +191,23 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
master = await loadConfig();
});
it('keeps both lookups on when the instance is not self-hosted', () => {
it('keeps the lookup on when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('leaves both lookups off on a self-hosted instance', () => {
it('leaves the lookup off on a self-hosted instance', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
it('lets a self-hosted operator switch each lookup on', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
);
expect(config.torExitList.enabled).toBe(true);
it('lets a self-hosted operator switch the lookup on', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true, {breachedPasswordCheck: true}));
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
);
expect(config.torExitList.enabled).toBe(false);
it('lets an operator switch the lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false, {breachedPasswordCheck: false}));
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
+12 -49
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
const normalized = new Set<string>();
for (const value of values) {
if (value.includes('/')) {
const range = parseIpBanEntry(value);
if (range?.type !== 'range') {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
}
normalized.add(range.canonical);
continue;
}
const parsed = parseIpAddress(value);
if (!parsed) {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
@@ -258,6 +267,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiPublic: master.endpoints.api,
apiClient: master.endpoints.api_client,
webApp: master.endpoints.app,
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
gateway: master.endpoints.gateway,
media: master.endpoints.media,
marketing: master.endpoints.marketing,
@@ -310,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,
},
@@ -474,23 +454,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
configured: master.instance.setup.configured,
},
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
directContactSpam: {
enabled: master.instance.abuse_policy.direct_contact_spam.enabled,
countryCodes: master.instance.abuse_policy.direct_contact_spam.country_codes,
distinctTargetThreshold: master.instance.abuse_policy.direct_contact_spam.distinct_target_threshold,
targetWindowMs: master.instance.abuse_policy.direct_contact_spam.target_window_ms,
action: master.instance.abuse_policy.direct_contact_spam.action,
},
},
domain: {
baseDomain: master.domain.base_domain,
},
-8
View File
@@ -1,9 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext, ApiServices, RequestScope} from '@app/api/ApiContext';
import {CassandraPhoneLookupRepository} from '@app/api/auth/services/PhoneLookupRepository';
import {Config} from '@app/api/Config';
import {getInboundSmsChallengeServiceInstance} from '@app/api/middleware/ServiceMiddleware';
import {
getGatewayService,
getKVClient,
@@ -17,9 +15,7 @@ import {
getContactChangeLogService,
getEmailDnsValidationService,
getEmailService,
getPhoneAttemptRiskService,
getRateLimitService,
getSmsService,
getUserActivityBuffer,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
@@ -39,14 +35,10 @@ function buildApiServices(): ApiServices {
media: getMediaService(),
email: getEmailService(),
emailDnsValidation: getEmailDnsValidationService(),
sms: getSmsService(),
worker: getWorkerService(),
snowflake: getSnowflakeService(),
rateLimit: getRateLimitService(),
contactChangeLog: getContactChangeLogService(),
inboundSmsChallenge: getInboundSmsChallengeServiceInstance(),
phoneLookup: new CassandraPhoneLookupRepository(),
phoneAttemptRisk: getPhoneAttemptRiskService(),
botMfaMirror: getBotMfaMirrorService(),
userActivityBuffer: getUserActivityBuffer(),
config: Config,
-204
View File
@@ -13,7 +13,6 @@ import {
BANNED_EMAIL_COLUMNS,
BANNED_FILE_SHA_COLUMNS,
BANNED_IP_COLUMNS,
BANNED_PHONE_PREFIX_COLUMNS,
BANNED_PHRASE_COLUMNS,
BANNED_PROFILE_SUBSTRING_COLUMNS,
BANNED_URL_COLUMNS,
@@ -22,15 +21,10 @@ import {
type BannedEmailRow,
type BannedFileShaRow,
type BannedIpRow,
type BannedPhonePrefixRow,
type BannedPhraseRow,
type BannedProfileSubstringRow,
type BannedUrlDomainRow,
type BannedUrlRow,
DISPOSABLE_EMAIL_DOMAIN_COLUMNS,
type DisposableEmailDomainRow,
SUSPICIOUS_EMAIL_DOMAIN_COLUMNS,
type SuspiciousEmailDomainRow,
} from '@app/api/database/types/AdminArchiveTypes';
import {
ADMIN_API_KEY_BY_CREATOR_COLUMNS,
@@ -69,11 +63,7 @@ import {
PASSWORD_RESET_TOKEN_COLUMNS,
type PasswordChangeTicketRow,
type PasswordResetTokenRow,
PHONE_TOKEN_COLUMNS,
type PhoneTokenRow,
USER_COUNTRY_HISTORY_COLUMNS,
USER_SSO_IDENTITY_COLUMNS,
type UserCountryHistoryRow,
type UserSsoIdentityRow,
WEBAUTHN_CREDENTIAL_COLUMNS,
type WebAuthnCredentialRow,
@@ -260,40 +250,6 @@ import {
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type MessageReportSubmissionByReporterRow,
} from '@app/api/database/types/ReportTypes';
import {
INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
INBOUND_SMS_CHALLENGE_COLUMNS,
type InboundSmsChallengeByUserRow,
type InboundSmsChallengeRow,
LATEST_RISK_CONTEXT_BY_USER_COLUMNS,
type LatestRiskContextByUserRow,
PHONE_LOOKUP_CACHE_COLUMNS,
PHONE_VERIFICATION_ATTEMPT_COLUMNS,
type PhoneLookupCacheRow,
type PhoneVerificationAttemptRow,
REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
REGISTRATION_EVENT_BY_IP_COLUMNS,
REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
type RegistrationEventByEmailDomainRow,
type RegistrationEventByIpRow,
type RegistrationEventByPlusAddressBaseRow,
type RegistrationEventBySubnetRow,
RISK_ASSESSMENT_BY_USER_COLUMNS,
RISK_ASSESSMENT_COLUMNS,
RISK_OUTCOME_BY_ASN_COLUMNS,
RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
RISK_OUTCOME_BY_IP_COLUMNS,
RISK_OUTCOME_BY_SUBNET_COLUMNS,
type RiskAssessmentByUserRow,
type RiskAssessmentRow,
type RiskOutcomeByAsnRow,
type RiskOutcomeByEmailDomainRow,
type RiskOutcomeByIpRow,
type RiskOutcomeBySubnetRow,
SUSPICIOUS_IP_COLUMNS,
type SuspiciousIpRow,
} from '@app/api/database/types/RiskTypes';
import {
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
@@ -788,21 +744,6 @@ export const BannedEmails = defineTable<BannedEmailRow, 'email_lower'>({
columns: BANNED_EMAIL_COLUMNS,
primaryKey: ['email_lower'],
});
export const BannedPhonePrefixes = defineTable<BannedPhonePrefixRow, 'prefix'>({
name: 'banned_phone_prefixes',
columns: BANNED_PHONE_PREFIX_COLUMNS,
primaryKey: ['prefix'],
});
export const SuspiciousEmailDomains = defineTable<SuspiciousEmailDomainRow, 'domain'>({
name: 'suspicious_email_domains',
columns: SUSPICIOUS_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['domain'],
});
export const DisposableEmailDomains = defineTable<DisposableEmailDomainRow, 'domain'>({
name: 'disposable_email_domains',
columns: DISPOSABLE_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['domain'],
});
export const BannedPhrases = defineTable<BannedPhraseRow, 'phrase'>({
name: 'banned_phrases',
columns: BANNED_PHRASE_COLUMNS,
@@ -888,12 +829,6 @@ export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'us
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('48 hours'),
});
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
name: 'phone_tokens',
columns: PHONE_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
name: 'auth_sessions',
columns: AUTH_SESSION_COLUMNS,
@@ -916,12 +851,6 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
primaryKey: ['user_id', 'session_id_hash'],
defaultTtlSeconds: seconds('30 days'),
});
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
name: 'user_country_history',
columns: USER_COUNTRY_HISTORY_COLUMNS,
primaryKey: ['user_id', 'country'],
defaultTtlSeconds: seconds('365 days'),
});
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
name: 'mfa_backup_codes',
columns: MFA_BACKUP_CODE_COLUMNS,
@@ -1174,139 +1103,6 @@ export const NcmecUserWorkflows = defineTable<NcmecUserWorkflowRow, 'user_id'>({
columns: NCMEC_USER_WORKFLOW_COLUMNS,
primaryKey: ['user_id'],
});
export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip' | 'created_at' | 'user_id', 'ip'>({
name: 'registration_events_by_ip',
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsBySubnet = defineTable<
RegistrationEventBySubnetRow,
'subnet' | 'created_at' | 'user_id',
'subnet'
>({
name: 'registration_events_by_subnet',
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByEmailDomain = defineTable<
RegistrationEventByEmailDomainRow,
'email_domain' | 'created_at' | 'user_id',
'email_domain'
>({
name: 'registration_events_by_email_domain',
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByPlusAddressBase = defineTable<
RegistrationEventByPlusAddressBaseRow,
'plus_address_base' | 'created_at' | 'user_id',
'plus_address_base'
>({
name: 'registration_events_by_plus_address_base',
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
partitionKey: ['plus_address_base'],
defaultTtlSeconds: seconds('30 days'),
});
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
name: 'latest_risk_context_by_user',
columns: LATEST_RISK_CONTEXT_BY_USER_COLUMNS,
primaryKey: ['user_id'],
});
export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
name: 'suspicious_ips',
columns: SUSPICIOUS_IP_COLUMNS,
primaryKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
{
name: 'risk_outcomes_by_ip',
columns: RISK_OUTCOME_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
},
);
export const RiskOutcomesBySubnet = defineTable<
RiskOutcomeBySubnetRow,
'subnet' | 'created_at' | 'user_id' | 'outcome_code',
'subnet'
>({
name: 'risk_outcomes_by_subnet',
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByEmailDomain = defineTable<
RiskOutcomeByEmailDomainRow,
'email_domain' | 'created_at' | 'user_id' | 'outcome_code',
'email_domain'
>({
name: 'risk_outcomes_by_email_domain',
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByAsn = defineTable<
RiskOutcomeByAsnRow,
'asn' | 'created_at' | 'user_id' | 'outcome_code',
'asn'
>({
name: 'risk_outcomes_by_asn',
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['asn'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
name: 'risk_assessments',
columns: RISK_ASSESSMENT_COLUMNS,
primaryKey: ['assessment_id'],
});
export const RiskAssessmentsByUser = defineTable<RiskAssessmentByUserRow, 'user_id' | 'created_at', 'user_id'>({
name: 'risk_assessments_by_user',
columns: RISK_ASSESSMENT_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
});
export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challenge_code'>({
name: 'inbound_sms_challenges',
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
primaryKey: ['challenge_code'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const InboundSmsChallengesByUser = defineTable<
InboundSmsChallengeByUserRow,
'user_id' | 'created_at',
'user_id'
>({
name: 'inbound_sms_challenges_by_user',
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
name: 'phone_lookup_cache',
columns: PHONE_LOOKUP_CACHE_COLUMNS,
primaryKey: ['phone'],
defaultTtlSeconds: seconds('7 days'),
});
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
name: 'phone_verification_attempts',
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
primaryKey: ['attempt_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
name: 'billing_customers',
columns: BILLING_CUSTOMER_COLUMNS,
+16 -4
View File
@@ -12,7 +12,16 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
import {
type core,
type input,
type output,
ZodNullable,
ZodObject,
ZodOptional,
type ZodSafeParseResult,
type ZodType,
} from 'zod';
initializeFluxerErrorMap();
@@ -46,8 +55,9 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknow
}
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
while (schema instanceof ZodOptional) schema = schema.unwrap();
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
while (schema instanceof ZodOptional || schema instanceof ZodNullable) schema = schema.unwrap();
const metadata = schema ? schemaMetadata.get(schema) : undefined;
if (metadata?.preserveEmptyValues) return obj;
if (typeof obj === 'string' && obj === '') return null;
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
if (obj !== null && typeof obj === 'object') {
@@ -59,7 +69,9 @@ function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot =
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
]),
);
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
if (!isRoot && !metadata?.preserveNullFields && Object.values(processed).every((value) => value === null)) {
return null;
}
return processed;
}
return obj;
+1 -76
View File
@@ -2,7 +2,7 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
@@ -20,21 +20,16 @@ import type {
BannedUrlDomainRow,
BannedUrlRow,
} from '@app/api/database/types/AdminArchiveTypes';
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import {
AdminAuditLogs,
BannedAvatarHashes,
BannedEmails,
BannedFileShas,
BannedIps,
BannedPhonePrefixes,
BannedPhrases,
BannedProfileSubstrings,
BannedUrlDomains,
BannedUrls,
DisposableEmailDomains,
SuspiciousEmailDomains,
} from '@app/api/Tables';
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
@@ -51,20 +46,10 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
where: SuspiciousEmailDomains.where.eq('domain'),
});
const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select();
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
where: DisposableEmailDomains.where.eq('domain'),
});
const createLoadDisposableEmailDomainsQuery = (limit?: number) =>
limit ? DisposableEmailDomains.select({limit}) : DisposableEmailDomains.select();
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
where: BannedPhrases.where.eq('phrase'),
});
const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select();
const LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY = BannedPhonePrefixes.select();
const IS_URL_BANNED_QUERY = BannedUrls.select({
where: BannedUrls.where.eq('url_canonical'),
});
@@ -263,59 +248,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.email_lower);
}
async isEmailDomainSuspicious(domain: string): Promise<boolean> {
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY.bind({domain: domainLower}));
return !!result;
}
async addSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(SuspiciousEmailDomains.insert({domain: domainLower}));
}
async removeSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
}
async loadAllSuspiciousEmailDomains(): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({}));
return rows.map((row) => row.domain);
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
if (!Config.blocklistFeeds.enabled) return false;
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_DISPOSABLE_QUERY.bind({domain: domainLower}));
return !!result;
}
async addDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(DisposableEmailDomains.insert({domain: domainLower}));
}
async removeDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(DisposableEmailDomains.deleteByPk({domain: domainLower}));
}
async listDisposableEmailDomains(limit?: number): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(createLoadDisposableEmailDomainsQuery(limit).bind({}));
return rows.map((row) => row.domain);
}
async isPhraseBanned(phrase: string): Promise<boolean> {
const phraseLower = canonicalizeStoredPhrase(phrase);
const result = await fetchOne<{
@@ -341,13 +273,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.phrase);
}
async loadAllBannedPhonePrefixes(): Promise<Array<string>> {
const rows = await fetchMany<{
prefix: string;
}>(LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY.bind({}));
return rows.map((row) => row.prefix);
}
async isUrlBanned(url: string): Promise<boolean> {
const canonical = url.toLowerCase();
const result = await fetchOne<{
-6
View File
@@ -37,8 +37,6 @@ import {
} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
@@ -81,10 +79,8 @@ export class AdminService {
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly suspiciousIpRepository: ISuspiciousIpRepository,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
this.auditService = new AdminAuditService(this.adminRepository, snowflake, {
@@ -97,7 +93,6 @@ export class AdminService {
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
suspiciousIpRepository: this.suspiciousIpRepository,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -111,7 +106,6 @@ export class AdminService {
kvDeletionQueue: getKVAccountDeletionQueue(),
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
stripe: this.stripe,
riskHistoryRepository: this.riskHistoryRepository,
reportService: this.reportService,
});
this.guildServiceAggregate = new AdminGuildService({
@@ -59,22 +59,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract isEmailDomainSuspicious(domain: string): Promise<boolean>;
abstract addSuspiciousEmailDomain(domain: string): Promise<void>;
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
abstract loadAllSuspiciousEmailDomains(): Promise<Array<string>>;
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
abstract addDisposableEmailDomain(domain: string): Promise<void>;
abstract removeDisposableEmailDomain(domain: string): Promise<void>;
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
abstract isPhraseBanned(phrase: string): Promise<boolean>;
abstract banPhrase(phrase: string): Promise<void>;
@@ -83,8 +67,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedPhrases(): Promise<Array<string>>;
abstract loadAllBannedPhonePrefixes(): Promise<Array<string>>;
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract isUrlBanned(url: string): Promise<boolean>;
@@ -47,7 +47,6 @@ import {
BulkBanFileShasRequest,
BulkJobResponse,
CheckAvatarHashRequest,
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodType} from 'zod';
@@ -76,17 +75,6 @@ const BLOCKLIST_CATALOG = [
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'email-domain-suspicious' as const,
description:
'Email domains flagged as suspicious. Registration is not blocked, but new accounts using the domain must verify a phone number before they can act on the platform. The list itself is not exposed to users.',
value_field: 'domain',
fields: [],
scoped: false,
supports_bulk_create: false,
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'phrase' as const,
description:
@@ -154,11 +142,6 @@ const BLOCKLIST_CATALOG = [
const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: string; remove: string}> = {
ip: {add: AdminACLs.BAN_IP_ADD, check: AdminACLs.BAN_IP_CHECK, remove: AdminACLs.BAN_IP_REMOVE},
email: {add: AdminACLs.BAN_EMAIL_ADD, check: AdminACLs.BAN_EMAIL_CHECK, remove: AdminACLs.BAN_EMAIL_REMOVE},
'email-domain-suspicious': {
add: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_ADD,
check: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_CHECK,
remove: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
},
phrase: {add: AdminACLs.BAN_PHRASE_ADD, check: AdminACLs.BAN_PHRASE_CHECK, remove: AdminACLs.BAN_PHRASE_REMOVE},
url: {add: AdminACLs.BAN_URL_ADD, check: AdminACLs.BAN_URL_CHECK, remove: AdminACLs.BAN_URL_REMOVE},
'url-domain': {
@@ -186,7 +169,6 @@ const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: s
const BLOCKLIST_AUDIT_TARGET_TYPES: Record<AdminBlocklistListType, string> = {
ip: 'ip',
email: 'email',
'email-domain-suspicious': 'email_domain',
phrase: 'phrase',
url: 'url',
'url-domain': 'url_domain',
@@ -255,8 +237,6 @@ async function checkBlocklistEntry(
return bans.checkIpBan({ip: entryValue});
case 'email':
return bans.checkEmailBan({email: entryValue});
case 'email-domain-suspicious':
return bans.checkSuspiciousEmailDomain({domain: entryValue});
case 'phrase':
return bans.checkPhraseBan({phrase: entryValue});
case 'url':
@@ -375,13 +355,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.banEmail(await parseBlocklistBody(BanEmailRequest, raw), adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.addSuspiciousEmailDomain(
await parseBlocklistBody(SuspiciousEmailDomainRequest, raw),
adminUserId,
auditLogReason,
);
break;
case 'phrase':
await bans.banPhrase(await parseBlocklistBody(BanPhraseRequest, raw), adminUserId, auditLogReason);
break;
@@ -624,9 +597,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.unbanEmail({email: entryValue}, adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.removeSuspiciousEmailDomain({domain: entryValue}, adminUserId, auditLogReason);
break;
case 'phrase':
await bans.unbanPhrase({phrase: entryValue}, adminUserId, auditLogReason);
break;
@@ -5,6 +5,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -25,14 +26,14 @@ export function CodesAdminController(app: HonoApp) {
operationId: 'create_admin_gift_codes',
summary: 'Issue gift codes',
description:
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
'Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.',
responseSchema: CodesResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
if (Config.instance.selfHosted) {
if (!isPremiumTieringActive()) {
throw new FeatureNotAvailableSelfHostedError();
}
const adminService = ctx.get('adminService');
@@ -16,12 +16,13 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
getPushRelayConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
import {
BrandingAssetUploadRequest,
@@ -34,9 +35,9 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
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';
@@ -63,8 +64,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
const [
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
pushRelay,
domainMigration,
captcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -72,19 +74,21 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
instanceConfigRepository.getInstanceMediaAdminConfig(),
instanceConfigRepository.getInstanceBillingAdminConfig(),
]);
return {
sso: {
@@ -104,8 +108,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
captcha,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -125,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,
@@ -139,6 +139,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
integrations,
media,
billing,
};
}
@@ -190,6 +191,109 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
return true;
}
function relayConsentStamp(
current: PushRelayConfig,
patch: PushRelayConfigUpdateRequest,
adminUserId: string,
): Partial<PushRelayConfig> {
const accepted = patch.relay_consent_accepted;
if (accepted === undefined || accepted === current.relay_consent_accepted) {
return {};
}
return accepted
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
}
function assertSelfHostedBillingSections(data: InstanceConfigUpdateRequest): void {
if (Config.instance.selfHosted) {
return;
}
if (data.billing) {
throw InputValidationError.create('billing', 'Billing is configured through the environment on this instance');
}
const branding = data.app_public?.branding;
if (!branding) {
return;
}
for (const field of ['premium_product_name', 'premium_info_url'] as const) {
if (readOptionalField(branding, field) !== undefined) {
throw InputValidationError.create(
`app_public.branding.${field}`,
'This setting is only available on self-hosted instances',
);
}
}
}
async function assertBillingCompatibleWithStoredPremiumMode(
billing: NonNullable<InstanceConfigUpdateRequest['billing']>,
): Promise<void> {
const requestedEnabled = readOptionalField(billing, 'enabled');
if (requestedEnabled !== true) {
return;
}
const policy = await getInstanceConfigRepository().readStoredInstancePolicyConfig();
if (policy.premium_mode === 'everyone') {
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
}
}
async function assertPremiumModeCompatibleWithStoredBilling(
requestedBillingEnabled: boolean | null | undefined,
): Promise<void> {
if (!Config.instance.selfHosted) {
return;
}
const repository = getInstanceConfigRepository();
const [policy, billing] = await Promise.all([
repository.readStoredInstancePolicyConfig(),
repository.readStoredInstanceBillingConfig(),
]);
if (policy.premium_mode === 'everyone') {
return;
}
const nextEnabled = requestedBillingEnabled === undefined ? billing.enabled : requestedBillingEnabled;
if (nextEnabled === true) {
throw InputValidationError.create(
'policy.premium_mode',
'Disable billing before switching the premium mode to everyone',
);
}
}
async function assertBillingCompatibleWithPremiumMode(data: InstanceConfigUpdateRequest): Promise<void> {
if (!Config.instance.selfHosted) {
return;
}
const requestedEnabled = data.billing ? readOptionalField(data.billing, 'enabled') : undefined;
const requestedPremiumMode = data.policy ? readOptionalField(data.policy, 'premium_mode') : undefined;
if (requestedEnabled === undefined && requestedPremiumMode === undefined) {
return;
}
const currentPremiumMode = (await getInstanceConfigRepository().getInstancePolicyConfig()).premium_mode;
const nextPremiumMode = requestedPremiumMode ?? currentPremiumMode;
if (nextPremiumMode !== 'everyone') {
return;
}
const nextEnabled =
requestedEnabled === undefined
? (await getInstanceConfigRepository().readStoredInstanceBillingConfig()).enabled
: requestedEnabled;
if (nextEnabled !== true) {
return;
}
if (requestedEnabled !== undefined) {
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
}
if (currentPremiumMode !== 'everyone') {
throw InputValidationError.create(
'policy.premium_mode',
'Disable billing before switching the premium mode to everyone',
);
}
}
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
@@ -245,6 +349,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
}),
async (ctx) => {
const data = ctx.req.valid('json');
assertSelfHostedBillingSections(data);
await assertBillingCompatibleWithPremiumMode(data);
const appPublicBeforeUpdate = completesInitialSetup(data, false)
? await instanceConfigRepository.getAppPublicConfig()
: null;
@@ -257,11 +363,23 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
}));
await getPushRelayConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
const patch = omitUndefinedFields(data.domain_migration);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
DomainMigrationConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
@@ -269,17 +387,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
if (data.experiment_delivery) {
@@ -351,6 +462,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
data.app_public.branding,
'status_page_incident_history_url',
),
premium_product_name: readOptionalField(data.app_public.branding, 'premium_product_name'),
premium_info_url: readOptionalField(data.app_public.branding, 'premium_info_url'),
})
: undefined,
legal: data.app_public.legal
@@ -378,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({
@@ -443,7 +547,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
});
}
if (data.policy) {
await applyInstancePolicyUpdate(ctx, data.policy);
await applyInstancePolicyUpdate(
ctx,
data.policy,
data.billing ? readOptionalField(data.billing, 'enabled') : undefined,
);
}
if (data.billing) {
await assertBillingCompatibleWithStoredPremiumMode(data.billing);
await instanceConfigRepository.setInstanceBillingConfig(
omitUndefinedFields({
enabled: readOptionalField(data.billing, 'enabled'),
stripe_secret_key: readOptionalField(data.billing, 'stripe_secret_key'),
stripe_webhook_secret: readOptionalField(data.billing, 'stripe_webhook_secret'),
default_currency: readOptionalField(data.billing, 'default_currency'),
prices: readOptionalField(data.billing, 'prices'),
country_currencies: readOptionalField(data.billing, 'country_currencies'),
legacy_prices: readOptionalField(data.billing, 'legacy_prices'),
automatic_tax: readOptionalField(data.billing, 'automatic_tax'),
tax_id_collection: readOptionalField(data.billing, 'tax_id_collection'),
terms_consent_required: readOptionalField(data.billing, 'terms_consent_required'),
}),
);
}
if (data.app_public?.setup) {
await instanceConfigRepository.setAppPublicConfig({
@@ -639,6 +764,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
async function applyInstancePolicyUpdate(
ctx: Context<HonoEnv>,
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
requestedBillingEnabled: boolean | null | undefined,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const appPublic = await instanceConfigRepository.getAppPublicConfig();
@@ -646,6 +772,9 @@ async function applyInstancePolicyUpdate(
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
if (policy.premium_mode === 'everyone') {
await assertPremiumModeCompatibleWithStoredBilling(requestedBillingEnabled);
}
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
@@ -713,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,

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