Compare commits

...
Author SHA1 Message Date
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
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
1264 changed files with 120817 additions and 85646 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=12.4.2
ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive
+5 -1
View File
@@ -38,7 +38,11 @@
"customizations": {
"vscode": {
"settings": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"erlang.includePaths": ["."],
"search.exclude": {
"**/_build/default/lib/fluxer_gateway": true
}
},
"extensions": [
"biomejs.biome",
-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:
+1
View File
@@ -32,6 +32,7 @@
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+3
View File
@@ -28,6 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
@@ -71,7 +71,6 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
@@ -155,7 +154,6 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build push
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
+25
View File
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -190,6 +198,9 @@ jobs:
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -322,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
@@ -398,12 +415,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +438,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
+2
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
@@ -25,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
Generated
+37 -10
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -1755,16 +1756,6 @@ dependencies = [
"zip",
]
[[package]]
name = "fluxer-content-update-frozen-snapshot"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
"sha2 0.11.0",
"tempfile",
]
[[package]]
name = "fluxer-dev"
version = "0.1.0"
@@ -1891,6 +1882,32 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-push"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.23.1",
"clap",
"fluxer-svc",
"futures",
"hmac 0.13.0",
"p256",
"percent-encoding",
"rand 0.10.2",
"reqwest",
"ring",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -2318,6 +2335,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3903,6 +3929,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
+1 -1
View File
@@ -7,9 +7,9 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/content/update-frozen-snapshot",
"tools/dev",
"tools/i18n_auto",
"fluxer_users",
+158 -3
View File
@@ -6,18 +6,173 @@
</p>
<p align="center">
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="https://fluxer.app/download">
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
<a href="https://docs.fluxer.app">
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="./LICENSE">
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
</p>
<p align="center">
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
</p>
# Fluxer
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [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] | | |
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
| | | [tar.gz (x64)][linux-targz-x64] | | |
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
## Linux package repositories
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
### Flatpak
Stable is on [Flathub][flathub], the easiest route on most desktops:
```sh
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
```sh
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
```
### Debian and Ubuntu
```sh
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
sudo apt update && sudo apt install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
sudo apt update && sudo apt install fluxer-canary
```
A `.deb` installed from a download only updates once its channel's entry is added.
### Fedora and RHEL
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
The repository is signed, so pacman needs the key once:
```sh
sudo pacman-key --init
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
```
`--lsign-key` is what makes pacman trust it. Then add the repository:
```sh
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
[fluxer]
SigLevel = Required TrustedOnly
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
REPO
sudo pacman -Syu fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
## Other ways to run it
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
- [Host your own instance][docs-selfhost] from this repository.
## Documentation
- [Documentation home][docs]
- [Downloads][docs-downloads]
- [Self-hosting][docs-selfhost]
## License
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
endorsement rights.
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[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
[docs]: https://docs.fluxer.app
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
-2
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
@@ -108,7 +107,6 @@ FLUXER_EMAIL_SMTP_PORT=1025
FLUXER_EMAIL_SMTP_USERNAME=dev
FLUXER_EMAIL_SMTP_PASSWORD=dev
FLUXER_EMAIL_SMTP_SECURE=false
FLUXER_SMS_ENABLED=false
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_SEARCH_ENGINE=meilisearch
+9 -5
View File
@@ -82,7 +82,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
@@ -99,15 +98,12 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless configured.
#FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
# Outside lookups, off unless turned on. The Tor exit list comes from
# onionoo.torproject.org and the breached password check asks
# Outside lookups, off unless turned on. The breached password check asks
# api.pwnedpasswords.com.
#FLUXER_TOR_EXIT_LIST_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
# The client address. Name the header your proxy actually writes, and turn the
@@ -148,6 +144,12 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Notification jobs the push container holds at once, 1 to 1000000.
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# Provider requests the push container sends at once, 1 to 65536.
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
@@ -246,6 +248,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
@@ -282,6 +285,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
#FLUXER_POSTGRES_SHM_SIZE=1gb
# The bundled Valkey holds durable state as well as cache, so it runs with an
# append-only file and with noeviction, which fails an over-limit write instead
+26 -6
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}
@@ -46,7 +45,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
@@ -75,7 +73,6 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
@@ -189,7 +186,7 @@ services:
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -295,14 +292,13 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint:
- /bin/sh
- -c
- >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
@@ -484,6 +480,30 @@ services:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
push:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
FLUXER_PUSH_SERVICE_PORT: "8126"
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@12.4.2 \
&& npm install -g pnpm@11.27.0 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
+482 -136
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,7 +10627,10 @@
"additionalProperties": false
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10635,7 +10742,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",
@@ -10646,7 +10755,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
},
@@ -10712,16 +10823,6 @@
},
"required": ["gif", "youtube", "bluesky"],
"additionalProperties": false
},
"deferred_phone_gate": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"window_hours": {"type": "number"},
"member_threshold": {"type": "number"}
},
"required": ["enabled", "window_hours", "member_threshold"],
"additionalProperties": false
}
},
"required": [
@@ -10732,8 +10833,7 @@
"premium_mode",
"services",
"services_resolved",
"services_available",
"deferred_phone_gate"
"services_available"
],
"additionalProperties": false
},
@@ -10945,19 +11045,24 @@
},
"required": ["attachment_decay"],
"additionalProperties": false
}
},
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
},
"required": [
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
"app_public",
"policy",
"integrations",
"media"
"media",
"billing"
],
"additionalProperties": false
},
@@ -11081,9 +11186,18 @@
"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"}]
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
@@ -11132,7 +11246,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"}}},
@@ -11289,18 +11405,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": {
@@ -12299,17 +12407,7 @@
},
"AdminBlocklistListType": {
"type": "string",
"enum": [
"ip",
"email",
"email-domain-suspicious",
"phrase",
"url",
"url-domain",
"file-sha",
"avatar-hash",
"profile-substring"
],
"enum": ["ip", "email", "phrase", "url", "url-domain", "file-sha", "avatar-hash", "profile-substring"],
"description": "The blocklist an entry belongs to"
},
"AdminBlocklistEntryUpdateRequest": {
@@ -12352,7 +12450,6 @@
"anyOf": [
{"$ref": "#/components/schemas/BanIpRequest"},
{"$ref": "#/components/schemas/BanEmailRequest"},
{"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"},
{"$ref": "#/components/schemas/BanPhraseRequest"},
{"$ref": "#/components/schemas/BanUrlRequest"},
{"$ref": "#/components/schemas/BanUrlDomainRequest"},
@@ -12729,7 +12826,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12747,7 +12844,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12777,7 +12874,7 @@
"maximum": 365
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12798,7 +12895,7 @@
"type": "string"
},
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12811,7 +12908,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 111,
"maxItems": 108,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12840,9 +12937,6 @@
"ban:email:add",
"ban:email:check",
"ban:email:remove",
"suspicious_email_domain:add",
"suspicious_email_domain:check",
"suspicious_email_domain:remove",
"ban:phrase:add",
"ban:phrase:check",
"ban:phrase:remove",
@@ -13155,19 +13249,6 @@
},
"required": ["phrase"]
},
"SuspiciousEmailDomainRequest": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"minLength": 1,
"maxLength": 253,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}$",
"description": "Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number."
}
},
"required": ["domain"]
},
"BanEmailRequest": {
"type": "object",
"properties": {
@@ -13410,12 +13491,12 @@
{
"name": "FORCE_INBOUND_PHONE_VERIFICATION",
"value": "2305843009213693952",
"description": "User is forced through inbound (expensive-destination) phone verification regardless of phone prefix, for debugging"
"description": "User is forced through inbound phone verification, for debugging"
},
{
"name": "NOT_SUSPICIOUS",
"value": "4611686018427387904",
"description": "User is permanently exempt from automatic suspicious-activity flagging on RPC session start (does not require a prior payment)"
"description": "User is permanently exempt from automatic suspicious-activity flagging"
}
]
},
@@ -15159,6 +15240,28 @@
{"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"}
]
},
"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"}]}
}
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
@@ -15178,45 +15281,84 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"ProfileTimezoneConfigUpdateRequest": {
"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},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"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"]
}
},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_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": {
@@ -15231,9 +15373,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",
@@ -15244,69 +15461,167 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"ProfileTimezoneConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"default_backend": {
"default": "standard",
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]
},
"enabled_backends": {
"default": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
"maxItems": 7,
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"default": true, "type": "boolean"},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"default": "voice-ns-v1", "type": "string", "minLength": 1, "maxLength": 64},
"rollout_salt": {
"default": "profile-timezone-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"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
}
},
"stereo_enabled": {"default": false, "type": "boolean"},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
"anonymous_enabled": {"default": false, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
},
"required": [
"enabled",
"config_version",
"default_backend",
"enabled_backends",
"allow_user_override",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"stereo_enabled",
"suppression_strength"
"anonymous_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": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushRelayConfigResponse": {
"type": "object",
"properties": {
"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))$"
},
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"GatewayRolloutConfigResponse": {
"type": "object",
"properties": {
@@ -15337,6 +15652,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": {
@@ -15433,14 +15760,29 @@
"suspicious_activity_flags": {"allOf": [{"$ref": "#/components/schemas/SuspiciousActivityFlags"}]},
"phone_verification_deferred": {
"type": "boolean",
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
"description": "Whether a stored phone requirement is deferred and not enforced"
},
"temp_banned_until": {"nullable": true, "type": "string"},
"pending_deletion_at": {"nullable": true, "type": "string"},
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"maxItems": 111, "type": "array", "items": {"type": "string"}},
"deletion_audit_log_reason": {
"nullable": true,
"description": "Private reason recorded with the pending deletion, null without the audit log view permission",
"type": "string"
},
"deletion_scheduled_by": {
"nullable": true,
"description": "ID of the account that scheduled the pending deletion, null when it was not recorded",
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
},
"deletion_scheduled_at": {
"nullable": true,
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 108, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
@@ -15481,6 +15823,9 @@
"pending_bulk_message_deletion_at",
"deletion_reason_code",
"deletion_public_reason",
"deletion_audit_log_reason",
"deletion_scheduled_by",
"deletion_scheduled_at",
"acls",
"traits",
"has_totp",
@@ -15588,9 +15933,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
}
);
}
}
+211 -123
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,9 +21,17 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -43,28 +51,6 @@ pub struct InstancePolicyResponse {
pub services_resolved: InstanceServicesResolved,
#[serde(default)]
pub services_available: InstanceServicesAvailable,
#[serde(default)]
pub deferred_phone_gate: DeferredPhoneGateResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeferredPhoneGateResponse {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub window_hours: f64,
#[serde(default)]
pub member_threshold: i64,
}
impl Default for DeferredPhoneGateResponse {
fn default() -> Self {
Self {
enabled: true,
window_hours: 6.0,
member_threshold: 50,
}
}
}
impl Default for InstancePolicyResponse {
@@ -78,7 +64,6 @@ impl Default for InstancePolicyResponse {
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -328,6 +313,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 {
@@ -342,6 +330,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,
}
}
}
@@ -350,6 +340,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)]
@@ -446,99 +440,183 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
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, Default, Deserialize, Serialize)]
#[serde(default)]
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, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub struct DomainMigrationConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
impl Default for DomainMigrationConfigResponse {
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(),
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(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct AltchaCaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -655,9 +733,17 @@ 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 domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[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)]
@@ -672,18 +758,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)]
@@ -866,6 +940,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)]
@@ -970,41 +1048,51 @@ mod tests {
use crate::api::generated::types as generated_types;
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() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
.expect("default altcha captcha config");
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
.expect("default profile timezone config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
serde_json::from_value(altcha_captcha.clone())
.expect("generated altcha captcha config contract");
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
serde_json::from_value(profile_timezone.clone())
.expect("generated profile timezone config contract");
let generated_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_altcha_captcha)
.expect("serializable generated altcha captcha config"),
altcha_captcha
);
assert_eq!(
serde_json::to_value(generated_profile_timezone)
.expect("serializable generated profile timezone config"),
profile_timezone
);
assert_eq!(
serde_json::to_value(generated_delivery)
@@ -1012,7 +1100,9 @@ mod tests {
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1025,25 +1115,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;
+1 -1
View File
@@ -80,7 +80,7 @@ async fn reports_list(
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
+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
+387 -351
View File
@@ -4,10 +4,12 @@ use crate::{
api::{
client::AdminApiClient,
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
@@ -17,10 +19,8 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
LimitRuleFilters, PremiumMode, ProfileTimezoneConfigUpdateRequest,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -193,7 +193,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,7 +205,27 @@ 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_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -334,6 +356,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,
@@ -448,7 +481,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;
@@ -489,6 +521,12 @@ fn parse_experiment_rollout_salt(
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
if !salt
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(Some(salt.to_owned()))
}
@@ -514,9 +552,9 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
@@ -524,107 +562,138 @@ 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(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
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_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
cost: parse_form_number(
form,
"altcha_captcha_cost",
"Cost",
*ALTCHA_CAPTCHA_COST_RANGE.start(),
*ALTCHA_CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"altcha_captcha_max_counter",
"Maximum counter",
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_profile_timezone_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
enabled: Some(form.bool_value("profile_timezone_enabled")),
rollout_basis_points: parse_form_number(
form,
"profile_timezone_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
@@ -686,6 +755,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")),
@@ -737,7 +807,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
_ => None,
};
let services = build_services_update(form);
let deferred_phone_gate = build_deferred_phone_gate_update(form);
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: None,
@@ -745,36 +814,11 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
direct_messages_disabled,
premium_mode,
services,
deferred_phone_gate,
}),
..Default::default()
}
}
fn build_deferred_phone_gate_update(
form: &MultiValueForm,
) -> Option<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),
@@ -909,7 +953,6 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
direct_messages_disabled: None,
premium_mode: None,
services: None,
deferred_phone_gate: None,
}),
..Default::default()
}
@@ -1236,77 +1279,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.stereo_enabled, Some(false));
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,
"stereo_enabled": 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!(
@@ -1351,13 +1323,13 @@ mod tests {
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
@@ -1367,192 +1339,256 @@ 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",
);
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));
}
#[test]
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!(
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 parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
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_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
);
let update = build_altcha_captcha_update(&form)
.expect("valid form")
.altcha_captcha
.expect("altcha captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
assert_eq!(update.anonymous_enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_altcha_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
);
}
#[test]
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"altcha_captcha_cost=999",
"Cost must be a whole number between 1000 and 100000",
),
(
"altcha_captcha_max_counter=1000001",
"Maximum counter must be a whole number between 100 and 1000000",
),
(
"altcha_captcha_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_altcha_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_profile_timezone_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
);
let update = build_profile_timezone_update(&form)
.expect("valid form")
.profile_timezone
.expect("profile timezone update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_profile_timezone_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"profile_timezone": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"domain_migration",
build_domain_migration_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[test]
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
assert_eq!(
build_profile_timezone_update(&form).expect_err("invalid field"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[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");
+41 -9
View File
@@ -73,15 +73,11 @@ pub async fn render(
.map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default();
let webauthn_credentials = if u.authenticator_types.contains(&2) {
client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default()
} else {
Vec::new()
};
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
config,
&u,
@@ -115,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,14 @@
use crate::{
api::types::{
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -23,6 +25,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 +119,6 @@ pub fn instance_config_page(
instance_config.self_hosted,
))
(sso_config_section(base, csrf_token, &instance_config.sso))
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
},
))
@if instance_config.self_hosted {
@@ -124,7 +126,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 +155,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,7 +174,9 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -196,7 +225,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 \
@@ -206,7 +240,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))
}
},
@@ -299,58 +333,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" }
@@ -358,8 +348,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"))
@@ -980,123 +970,163 @@ 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 {
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Not accepted", BadgeVariant::Default)
};
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(
"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_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" { "Relay consent" }
(badge(status.0, status.1))
}
(checkbox(
"push_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"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"
}
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(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 Push Relay Settings"))
}))
}
}
},
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
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(
"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.",
"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_voice_noise_suppression"} {
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 " (voice_noise_suppression.config_version)
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"domain_migration_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.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 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."
"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" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"voice_ns_allow_user_override",
"domain_migration_standalone_forwarding",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
"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(
"voice_ns_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.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(
"voice_ns_rollout_salt",
"domain_migration_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
&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(
"voice_ns_included_user_ids",
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
@@ -1104,19 +1134,51 @@ fn voice_noise_suppression_section(
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
domain_migration.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 \
digits. Invalid entries prevent the save. Blank entries and duplicate \
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(
"voice_ns_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,
@@ -1124,62 +1186,326 @@ fn voice_noise_suppression_section(
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
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, so this is the per-user kill switch."
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
(form_actions(html! {
(submit_button("Save Domain Migration Configuration"))
}))
}
}
},
)
}
fn altcha_captcha_section(
base: &str,
csrf_token: &str,
altcha_captcha: &AltchaCaptchaConfigResponse,
) -> Markup {
let status = if altcha_captcha.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
section_card_with_description(
"ALTCHA Captcha",
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
selected requesters. The API issues and verifies every challenge itself, so no third \
party is involved. Requests only need a captcha where one is already required, so this \
does nothing while captcha is off for the instance.",
html! {
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (altcha_captcha.config_version)
}
}
(checkbox(
"altcha_captcha_enabled",
"true",
"Serve ALTCHA to the selected requesters",
altcha_captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
requester gets the configured provider and ALTCHA answers are rejected."
}
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
(checkbox(
"altcha_captcha_anonymous_enabled",
"true",
"Serve ALTCHA to logged-out requests",
altcha_captcha.anonymous_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Covers registration, login and password reset. These requests have no \
account to bucket, so this switch applies to all of them at once."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"altcha_captcha_rollout_basis_points",
"Rollout (basis points)",
&altcha_captcha.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"altcha_captcha_rollout_salt",
"Rollout Salt",
&altcha_captcha.rollout_salt,
ALTCHA_CAPTCHA_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
"altcha_captcha_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
altcha_captcha.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
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."
"One snowflake per line, or comma separated. These users get ALTCHA \
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
(number_field(
"altcha_captcha_cost",
"Cost (PBKDF2 iterations per attempt)",
&altcha_captcha.cost.to_string(),
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
"1",
Some("The API spends one attempt at this cost to issue each challenge."),
))
(number_field(
"altcha_captcha_max_counter",
"Maximum counter",
&altcha_captcha.max_counter.to_string(),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
))
(form_actions(html! {
(submit_button("Save ALTCHA Configuration"))
}))
}
}
},
)
}
fn profile_timezone_section(
base: &str,
csrf_token: &str,
profile_timezone: &ProfileTimezoneConfigResponse,
) -> Markup {
let status = if profile_timezone.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = profile_timezone.included_user_ids.join("\n");
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
section_card_with_description(
"Profile Timezone",
"Lets the selected users save a time zone in profile settings and show their local time \
on their profile. Users outside the rollout cannot change it, and a saved time zone \
stays hidden from everyone while its owner is outside the rollout.",
html! {
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (profile_timezone.config_version)
}
}
(checkbox(
"voice_ns_stereo_enabled",
"profile_timezone_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
"Serve profile timezone to the selected users",
profile_timezone.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
"Off is the safe state and the kill switch. With this unchecked nobody \
sees the setting and every saved time zone is hidden."
}
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."),
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"profile_timezone_rollout_basis_points",
"Rollout (basis points)",
&profile_timezone.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"profile_timezone_rollout_salt",
"Rollout Salt",
&profile_timezone.rollout_salt,
PROFILE_TIMEZONE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get profile \
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"profile_timezone_include_premium_users",
"true",
"Include premium users",
profile_timezone.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(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
profile_timezone.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(
"profile_timezone_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
(submit_button("Save Profile Timezone Configuration"))
}))
}
}
@@ -1195,7 +1521,7 @@ fn experiment_delivery_section(
section_card_with_description(
"Experiment Delivery",
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the one above. Raising the interval sheds \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
html! {
@@ -1779,7 +2105,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."
};
@@ -1801,44 +2127,73 @@ 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 voice_noise_suppression_section_shows_list_counts_and_caps() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
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 {
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
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,
))
}
+23 -4
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;
@@ -114,7 +123,10 @@ pub fn users_list_page(
let content = html! {
div class="space-y-6" {
(page_header("Users", None))
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
}
(results_markup)
@@ -212,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! {
@@ -233,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))
@@ -298,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" {
@@ -340,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)
+182 -28
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,23 +400,52 @@ 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": [],
"stereo_enabled": false,
"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
},
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 0,
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -427,7 +464,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": {
@@ -453,12 +492,7 @@ 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},
@@ -525,6 +559,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
}"##;
@@ -534,21 +598,63 @@ 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.altcha_captcha.enabled);
assert_eq!(resp.altcha_captcha.config_version, 3);
assert!(resp.altcha_captcha.anonymous_enabled);
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
assert_eq!(resp.altcha_captcha.max_counter, 10000);
assert!(resp.profile_timezone.enabled);
assert_eq!(resp.profile_timezone.config_version, 2);
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert_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(
@@ -557,6 +663,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},",
"",
@@ -570,6 +677,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#"{
@@ -750,7 +902,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
@@ -844,7 +997,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,
+13 -17
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",
][..],
),
@@ -816,6 +818,9 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
@@ -951,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,
@@ -1175,27 +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": [],
"stereo_enabled": false,
"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,
+2 -2
View File
@@ -5,7 +5,7 @@ FROM node:26-trixie-slim AS base
WORKDIR /usr/src/app
RUN npm install -g pnpm@12.4.2
RUN npm install -g pnpm@11.27.0
FROM base AS deploy
@@ -57,7 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libvips42t64 && \
rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@12.4.2
RUN npm install -g pnpm@11.27.0
COPY --from=deploy /out ./
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
+2 -2
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:",
@@ -94,5 +94,5 @@
"typescript": "catalog:ts7",
"vitest": "catalog:"
},
"packageManager": "pnpm@12.4.2"
"packageManager": "pnpm@11.27.0"
}
+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:",
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
remoteIp?: string;
}
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_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 implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
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}: VerifyCaptchaParams): 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);
}
}
@@ -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';
}
+14 -7
View File
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
@@ -34,12 +36,9 @@ async function upsertKvRow(
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds?: number,
ttlSeconds: number,
): Promise<void> {
const expiresAt =
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
? new Date(Date.now() + ttlSeconds * 1000)
: null;
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
@@ -112,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,
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
@@ -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));
}
+5 -3
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
import pg from 'pg';
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
}
private async openPool(): Promise<void> {
const pool = new pg.Pool({
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
connectionString: this.config.url || undefined,
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
port: this.config.url ? undefined : (this.config.port ?? 5432),
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
max: this.config.maxConnections ?? 20,
});
scramMaxIterations: 0,
};
const pool = new pg.Pool(poolConfig);
this.observePoolConnections(pool);
try {
const client = await pool.connect();
-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 -51
View File
@@ -1,9 +1,9 @@
// 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 {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
@@ -83,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}`);
@@ -92,18 +100,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -157,7 +153,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
const s3Buckets = s3Config.buckets ?? {
cdn: '',
uploads: '',
downloads: '',
reports: '',
harvests: '',
};
@@ -174,10 +169,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -276,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,
@@ -290,8 +282,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
donationProxyKey,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
gift: extractHostname(master.endpoints.gift),
marketing: extractHostname(master.endpoints.marketing),
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
},
@@ -304,7 +294,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -331,26 +320,12 @@ 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,
},
@@ -495,19 +470,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
configured: master.instance.setup.configured,
},
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_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,
},
@@ -523,7 +485,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
validateResponses: resolveValidateResponses(master),
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
-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,
+23 -189
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,
@@ -341,6 +297,7 @@ import {
type UsersPendingDeletionRow,
} from '@app/api/database/types/UserTypes';
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {seconds} from 'itty-time';
export const Users = defineTable<UserRow, 'user_id'>({
name: 'users',
@@ -499,16 +456,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
name: 'guild_audit_logs_v2',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
name: 'guild_audit_logs_v2_by_user',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
name: 'guild_audit_logs_v2_by_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUserAction = defineTable<
GuildAuditLogRow,
@@ -517,6 +477,7 @@ export const GuildAuditLogsByUserAction = defineTable<
name: 'guild_audit_logs_v2_by_user_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
name: 'guild_membership_metadata',
@@ -655,6 +616,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
name: 'recent_mentions',
columns: RECENT_MENTION_COLUMNS,
primaryKey: ['user_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
interface RecentMentionsByGuildRow {
@@ -678,6 +640,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
name: 'recent_mentions_by_guild',
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
primaryKey: ['user_id', 'guild_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
name: 'saved_messages',
@@ -688,6 +651,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
name: 'push_subscriptions',
columns: PUSH_SUBSCRIPTION_COLUMNS,
primaryKey: ['user_id', 'subscription_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
name: 'payments',
@@ -780,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,
@@ -854,11 +803,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
name: 'email_verification_tokens',
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
name: 'password_reset_tokens',
columns: PASSWORD_RESET_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokensByUserId = defineTable<
{
@@ -870,16 +821,13 @@ export const PasswordResetTokensByUserId = defineTable<
name: 'password_reset_tokens_by_user_id',
columns: ['user_id', 'token_'],
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('24 hours'),
});
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
name: 'email_revert_tokens',
columns: EMAIL_REVERT_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
});
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
name: 'phone_tokens',
columns: PHONE_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('48 hours'),
});
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
name: 'auth_sessions',
@@ -901,11 +849,7 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
name: 'auth_session_tombstones',
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
primaryKey: ['user_id', 'session_id_hash'],
});
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
name: 'user_country_history',
columns: USER_COUNTRY_HISTORY_COLUMNS,
primaryKey: ['user_id', 'country'],
defaultTtlSeconds: seconds('30 days'),
});
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
name: 'mfa_backup_codes',
@@ -932,6 +876,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
name: 'ip_authorization_tokens',
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('30 minutes'),
});
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
name: 'authorized_ips_v2',
@@ -1057,26 +1002,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
name: 'oauth2_authorization_codes',
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
primaryKey: ['code'],
defaultTtlSeconds: seconds('10 minutes'),
});
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
name: 'oauth2_access_tokens',
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_access_tokens_by_user',
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
name: 'oauth2_refresh_tokens',
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_refresh_tokens_by_user',
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('30 days'),
});
interface WebhooksByChannelRow {
@@ -1117,12 +1067,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
name: 'jobs_by_id',
columns: JOB_BY_ID_COLUMNS,
primaryKey: ['job_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
name: 'jobs_by_day_bucket',
columns: JOB_BY_DAY_BUCKET_COLUMNS,
primaryKey: ['bucket_day', 'created_at', 'job_id'],
partitionKey: ['bucket_day'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
name: 'jobs_active',
@@ -1133,11 +1085,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
name: 'attachment_upload_traces_by_key',
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
primaryKey: ['upload_key'],
defaultTtlSeconds: seconds('30 days'),
});
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
name: 'attachment_upload_traces_by_attachment',
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
primaryKey: ['attachment_id'],
defaultTtlSeconds: seconds('30 days'),
});
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
name: 'ncmec_attachment_submissions',
@@ -1149,126 +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'],
});
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'],
});
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'],
});
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'],
});
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'],
});
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'],
},
);
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'],
});
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'],
});
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'],
});
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'],
});
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'],
});
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
name: 'phone_lookup_cache',
columns: PHONE_LOOKUP_CACHE_COLUMNS,
primaryKey: ['phone'],
});
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
name: 'phone_verification_attempts',
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
primaryKey: ['attempt_id'],
});
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;
+18 -75
View File
@@ -2,7 +2,15 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import type {
AdminAuditLogRow,
BannedAvatarHashRow,
@@ -12,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';
@@ -43,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'),
});
@@ -255,58 +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> {
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<{
@@ -332,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<{
@@ -395,6 +329,15 @@ export class AdminRepository implements IAdminRepository {
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
}
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
return executeConditional(
BannedFileShas.conditionalDeleteByPk(
{sha256_hex: sha256Hex.toLowerCase()},
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
),
);
}
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
}
-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({
+2 -18
View File
@@ -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>;
@@ -109,6 +91,8 @@ export abstract class IAdminRepository {
abstract unbanFileSha(sha256Hex: string): Promise<void>;
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;

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