Compare commits

...
Author SHA1 Message Date
HampusandGitHub 38b7c63431 fix(admin): declare gateway cluster_metrics in node stats (#2728) 2026-09-12 22:48:22 +02:00
HampusandGitHub 57d08cd091 fix(api): keep stickers on messages sent to personal notes (#2727) 2026-09-12 20:20:26 +02:00
HampusandGitHub 91e2d31614 style(voice): format the room_finished webhook test 2026-09-12 16:05:26 +02:00
HampusandGitHub d375dc7946 fix(ci): stop a new component blocking every other image promote 2026-09-12 16:01:47 +02:00
HampusandGitHub 3fffce2a4a fix(voice): correct the room_finished test harness types (#2723) 2026-09-12 15:49:47 +02:00
HampusandGitHub 376c509083 docs(self-host): tighten the env example comments (#2722) 2026-09-12 15:39:24 +02:00
HampusandGitHub 156315fd5a docs: drop exact counts that go stale when inventory changes (#2721) 2026-09-12 15:39:07 +02:00
HampusandGitHub c7b9e9b9bd fix(voice): stop room_finished evicting a whole channel (#2720) 2026-09-12 15:38:50 +02:00
HampusandGitHub 12397032e3 feat(voice): add the recon service and remove the old worker (#2719) 2026-09-12 15:38:32 +02:00
HampusandGitHub 4a285cbb11 fix(docs): drop the orphaned voice command footnote (#2718) 2026-09-12 01:45:27 +02:00
HampusandGitHub 6d600990fe fix(app): derive unread from an ack timestamp floor (#2717) 2026-09-12 01:40:36 +02:00
HampusandGitHub e1bc6c2f7e refactor(voice): remove the unused voice state ack (#2716) 2026-09-12 01:37:45 +02:00
HampusandGitHub 3e79530389 fix(app): defer non-critical gateway dispatches (#2715) 2026-09-12 01:36:04 +02:00
HampusandGitHub d0c84b3d9b fix(voice): apply noise suppression in the mic test (#2714) 2026-09-12 01:14:36 +02:00
HampusandGitHub 3affd295e8 feat(guild): require opt-in for emoji and sticker cloning (#2712) 2026-09-12 00:33:23 +02:00
HampusandGitHub 7b15e5be0f fix(admin): reject synthetic user ids on mutating routes (#2711) 2026-09-12 00:23:04 +02:00
HampusandGitHub adab646d1e fix(schema): preserve WebAuthn payloads and align fixtures (#2710) 2026-09-12 00:19:44 +02:00
HampusandGitHub de1fd95a99 refactor(schema): simplify validation and OpenAPI generation (#2709) 2026-09-11 23:24:26 +02:00
HampusandGitHub 4bc5593f9f chore(i18n): translate the voice quality catalog additions (#2707) 2026-09-11 22:59:12 +02:00
HampusandGitHub 172791316b feat(voice): add noise suppression backends and rollout (#2706) 2026-09-11 22:24:05 +02:00
HampusandGitHub b30a4f5d14 fix(admin): omit synthetic accounts from user lookup and search (#2705) 2026-09-11 22:06:29 +02:00
HampusandGitHub f254ed679b fix(app): never lower the read-state unread watermark (#2704) 2026-09-11 22:02:24 +02:00
HampusandGitHub 258fe6f742 feat(voice): add audio bitrate guild features and 96 kbps cap (#2703) 2026-09-11 22:00:15 +02:00
HampusandGitHub cfa20b7093 fix(admin): let voice restriction lists be cleared (#2701) 2026-09-11 21:28:26 +02:00
HampusandGitHub 569146c5bc fix(app): pick favorite GIF preview kind from content type (#2696) 2026-09-11 21:06:00 +02:00
HampusandGitHub 1b1d48b05e feat(voice): soft connection limits for voice servers (#2694) 2026-09-11 20:05:14 +02:00
HampusandGitHub cadca2c18e test(voice): build watch attempt keys from the shared builder (#2693) 2026-09-11 19:44:34 +02:00
HampusandGitHub 2e3f78b3c6 fix(app): stop restarting the read-state ack batch window (#2689) 2026-09-11 16:26:34 +02:00
HampusandGitHub b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
HampusandGitHub e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot]andGitHub ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot]andGitHub c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
HampusandGitHub 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
HampusandGitHub 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
HampusandGitHub 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
HampusandGitHub d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
HampusandGitHub f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
HampusandGitHub 905af5dd5a fix(app): shrink stored favorite gifs and raise their budget (#2643) 2026-09-10 18:43:42 +02:00
HampusandGitHub 5fea319f4e fix(app): stop other youtube embeds when one starts playing (#2642) 2026-09-10 18:42:30 +02:00
HampusandGitHub 01fd11fea9 fix(api): unexport the search lookup result type (#2641) 2026-09-10 18:24:16 +02:00
HampusandGitHub d028679b90 fix(api): batch the message lookups behind message search (#2640) 2026-09-10 18:18:49 +02:00
HampusandGitHub 4a93b677af feat(api): retire prices safely and add a self-serve switch (#2637) 2026-09-10 17:28:16 +02:00
HampusandGitHub d79cd99050 refactor(api): tidy message helper internals (#2636) 2026-09-10 02:07:03 +02:00
HampusandGitHub 167862a8a6 perf(api): harvest messages a page at a time (#2633) 2026-09-09 20:59:38 +02:00
HampusandGitHub 48b569b9d4 fix(api): harvest every authored message, not the first 100000 (#2631) 2026-09-09 11:52:55 +02:00
HampusandGitHub 75be6aa492 fix(gateway): deliver mention updates to passive sessions (#2632) 2026-09-09 11:31:17 +02:00
HampusandGitHub 9fe65d5036 fix(api): honour the configured S3 addressing on uploads (#2626) 2026-09-09 11:26:30 +02:00
HampusandGitHub bfa9bf221d docs(api): tidy up the reference prose (#2628) 2026-09-09 02:11:38 +02:00
HampusandGitHub 184eeb0846 fix(gateway): keep dispatch ordered under broadcaster load (#2627) 2026-09-09 02:06:36 +02:00
HampusandGitHub 0eff26a1c9 test(config): match the configurable client-IP trust defaults (#2625) 2026-09-09 01:32:36 +02:00
HampusandGitHub d729f641ff fix(app): do not crash when the browser translates the page (#2624) 2026-09-09 01:24:14 +02:00
HampusandGitHub 044a2c101d feat(self-hosting): make the bundled services configurable (#2621) 2026-09-09 01:17:58 +02:00
HampusandGitHub 38e2c8db3e fix(admin): keep server traits when an operator saves traits (#2622) 2026-09-09 00:13:07 +02:00
HampusandGitHub 1b22d14f3d feat(self-hosting): run postgres or the object store outside (#2620) 2026-09-08 23:36:52 +02:00
HampusandGitHub 98cceae59d fix(i18n): point static catalog translation at weblate (#2619) 2026-09-08 23:10:10 +02:00
HampusandGitHub 3e32414849 test(config): expand the shipped stack on another port (#2612) 2026-09-08 23:10:00 +02:00
HampusandGitHub 7707b9531c chore(i18n): translate the new setup and email domain strings (#2613) 2026-09-08 22:57:07 +02:00
HampusandHampus Kraft 86745e01e9 docs(operator): cover serving on a non-default port (#2611) 2026-09-08 22:18:19 +02:00
HampusandHampus Kraft 098830a95a fix(admin): compare the request origin against an origin (#2610) 2026-09-08 22:18:10 +02:00
HampusandHampus Kraft cf83f66911 fix(app): keep the new admin when setup meets one 401 (#2609) 2026-09-08 22:18:02 +02:00
HampusandHampus Kraft c577b97f35 fix(api): reject a mail-less email domain by its own code (#2608) 2026-09-08 22:17:53 +02:00
HampusandGitHub 8cc485cf81 fix(self-host): tie the public address to a single origin (#2605) 2026-09-08 22:17:39 +02:00
1148 changed files with 106211 additions and 66749 deletions
+3
View File
@@ -33,6 +33,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:recon:
- changed-files:
- any-glob-to-any-file: fluxer_recon/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+8
View File
@@ -525,6 +525,7 @@ jobs:
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -602,7 +603,9 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
@@ -660,3 +663,8 @@ jobs:
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build recon
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-recon
dockerfile: fluxer_recon/Dockerfile
build-version: ${{ inputs['build-version'] }}
+25
View File
@@ -426,6 +426,31 @@ jobs:
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
lint:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check formatting and lint
run: pnpm exec biome ci .
- name: Lint JSX for browser-translation safety
run: pnpm exec eslint . --max-warnings 0
i18n:
runs-on: ubuntu-24.04
timeout-minutes: 25
Generated
+20
View File
@@ -1866,6 +1866,25 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-recon"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"hmac 0.13.0",
"reqwest",
"scylla",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -1940,6 +1959,7 @@ dependencies = [
"anyhow",
"chrono",
"fluxer-svc",
"fluxer_common",
"futures",
"moka",
"rmp-serde",
+1
View File
@@ -15,6 +15,7 @@ members = [
"fluxer_users",
"fluxer_unfurl",
"packages/markdown_parser/rust",
"fluxer_recon",
]
exclude = [
"fluxer_marketing",
+23 -1
View File
@@ -84,7 +84,18 @@
},
"useConst": "error",
"noNonNullAssertion": "off",
"noParameterAssign": "off"
"noParameterAssign": "off",
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@lingui/react": {
"importNames": ["I18nProvider"],
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
}
}
}
}
},
"a11y": {
"recommended": true,
@@ -116,10 +127,21 @@
},
"assist": {"actions": {"source": {"organizeImports": "on"}}},
"overrides": [
{
"includes": ["fluxer_app/src/**/*.tsx"],
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
},
{
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
},
{
"includes": [
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
],
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
+5 -1
View File
@@ -65,10 +65,14 @@ FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
FLUXER_RECON_MODE=observing
FLUXER_RECON_EXPECTED_ROOMS=64
FLUXER_RECON_WARMUP_SECONDS=15
FLUXER_RECON_MAX_HOT_ROOMS=8
FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
FLUXER_APP_DEV_PORT=3000
FLUXER_APP_PROXY_PORT=8773
FLUXER_STATIC_DIR=fluxer_app/dist
+136 -52
View File
@@ -1,18 +1,21 @@
# Every variable docker-compose.yml reads from this file is named here:
# uncommented when it has no default, commented with its default when it has one.
# A name absent from this file is one Compose does not forward, and it reaches a
# service only through a Compose override file that adds it to that service's
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
# a Compose edit forgets the matching line here.
# Every variable docker-compose.yml reads is named here, uncommented when it has
# no default and commented with its default when it has one. A name absent from
# this file reaches a service only through a Compose override. Compose expands
# top to bottom, so a line using ${...} must sit below every name it reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
# The lines above are the address browsers use, and every advertised endpoint
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
# needs the matching one set as well. Complete recipes sit beside them.
# How browsers reach this instance.
#
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
# Point DNS at this host and there is nothing else to configure.
# Point DNS at this host.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
@@ -33,50 +36,70 @@ FLUXER_PUBLIC_PORT=443
# address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The public origin browsers use, without a trailing slash. Derived from the three
# values above and correct for the usual https-on-443 setup, so leave it alone
# unless you serve Fluxer on a non-default port, where the port must appear here.
# The origin browsers see, without a trailing slash. Leave it unset and each
# service builds one from the three values at the top of this file. Set it and it
# wins: every service reads the host, the scheme and the port out of it and
# ignores those three names. Use it when browsers reach the instance on a host
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
# and an optional port and nothing after them, or the services refuse to start.
# It does not move the edge listener or the published ports either, so set the
# publish below to the port written here.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
# file win, so a value here is discarded under the proxy overlay with no warning.
# Set it only for an unusual default-mode layout, such as serving several
# hostnames or binding a non-default TLS port.
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
# Overrides the address the edge listens on inside its container. Compose builds
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
# site by host and ignores the port in the Host header, so a request arriving on
# a non-default published port still lands on this site. Put a port in this value
# only if you also publish that same container port below, or nothing will be
# listening where the publish points. Honoured in the default mode only:
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
# literal :80, and Compose lets the last file win, so a value here is discarded
# under either overlay with no warning. Set it for an unusual default-mode
# layout, such as serving several hostnames. Write the scheme into it: a bare
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
# The old name for the value above. It is read only when
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
# unset, so an existing .env keeps the listener it already had.
#FLUXER_CADDY_SITE_ADDRESS=
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
# stay below the two values it reads. Above them it silently expands to a bare
# host with a trailing colon.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
#FLUXER_HTTP_PORT=19080
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
# the container, so change only these when something else already owns the
# standard ports or another proxy sits in front. Both take an optional bind
# address in front of the port, and 127.0.0.1 keeps the publish off every
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
# together, because HTTP/3 needs both on the same port.
# Host side of the edge's publishes, and the only names that decide which host
# ports Fluxer binds. The container side is fixed. Container 80 carries the
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
# scheme, and the site itself under an http one. Container 443 carries the TLS
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
# HTTP/3 needs both on the same port. Both take an optional bind address in front
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
# them different host ports: the same host port on both is two publishes of one
# port and the edge refuses to start.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
# certificate is issued if a router in front forwards public 80 to this host and
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
# cannot.
#FLUXER_PUBLIC_PORT=8443
#FLUXER_HTTPS_PORT=8443
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
# binds host 80. Under an http scheme nothing listens on container 443, so the
# last line parks that publish on loopback for a host that wants 443 for
# something else. Drop it and 443 is published and idle, which is what earlier
# releases did.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_HTTP_PORT=19080
#FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
# still moves that one publish. Set the line below and plain docker compose
# commands pick the file up, or add it to your own -f flags if you pass any. The
# file uses the !override tag, which needs Compose 2.24.4 or newer.
@@ -88,6 +111,60 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
# The stack ships its own Postgres and its own object store, and points at both
# by service name. Set these to run either one outside the stack. Leave them
# unset and the bundled services are used. Taking a service out of the stack
# means an upgrade skips the backup step that reaches into it, and backing that
# store up belongs to whoever runs it.
#FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer
#FLUXER_POSTGRES_USERNAME=fluxer
#FLUXER_POSTGRES_SSL=true
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
#FLUXER_S3_REGION=eu-central-1
#FLUXER_S3_FORCE_PATH_STYLE=false
# Bucket names. The bundled object store creates whichever names these hold, so
# the two stay in step. An object store outside the stack needs the buckets to
# 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
# The rest of the bundled services, pointed somewhere else the same way. Leave a
# line unset and the service in the stack is used. Taking a service out of the
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
# replaces docker-compose.yml.
#FLUXER_KV_URL=redis://cache.example.com:6379/0
#FLUXER_NATS_URL=nats://mq.example.com:4222
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
#FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# Voice off. The livekit service still runs until an override file takes it out.
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless the instance is configured for it.
#FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
# The client address. Set the header name a proxy in front actually writes, and
# turn the trust off when nothing sits in front, because a trusted header an
# attacker can set is a spoofed client address.
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
#FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. trace, debug, info, warn, error or fatal. Every
# service names the object storage endpoint and its addressing at info on start,
# so a bucket that answers 404 is visible without raising this.
#LOG_LEVEL=debug
FLUXER_S3_ACCESS_KEY=fluxer
FLUXER_S3_SECRET_KEY=CHANGE_ME
@@ -153,9 +230,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
# Set it only when LiveKit is served from another host.
# The URL browsers use for voice signalling. Compose builds it from
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
# leading http to ws itself. Set it only when LiveKit is served from another
# host.
#FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these in ICE candidates, so the host must
@@ -166,7 +245,7 @@ LIVEKIT_API_SECRET=CHANGE_ME
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
@@ -194,11 +273,11 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The four reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
# The reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from services whose death takes the instance down. They reserve
# nothing. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
@@ -279,11 +358,16 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# In-flight request ceiling for the four services Compose forwards it to: the
# users and messages routers and their shards. The Rust built-in defaults are 192
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
# In-flight request ceiling for the services Compose forwards it to: the users
# and messages routers and their shards. Leave it unset and each service uses its
# built-in default. Set it and the one value replaces that default on all of
# them, so size it for the busiest. The built-in defaults are 192 for
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them, and the api
# turns that rejection into a 503.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
+46 -35
View File
@@ -2,61 +2,63 @@ name: fluxer
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
FLUXER_ENV: production
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
FLUXER_SELF_HOSTED: "true"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
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_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
FLUXER_SVC_NATS_URL: nats://nats:4222
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_SVC_SHARD_COUNT: "1"
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_URL: http://meilisearch:7700
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_REGION: us-east-1
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
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_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}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
AWS_DEFAULT_REGION: us-east-1
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
FLUXER_LIVEKIT_ENABLED: "true"
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
@@ -71,16 +73,16 @@ 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: "false"
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
@@ -132,7 +134,7 @@ services:
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
@@ -287,11 +289,16 @@ services:
environment:
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
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 fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$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
@@ -494,6 +501,10 @@ services:
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
@@ -554,7 +565,7 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -572,7 +583,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -622,7 +633,7 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -640,7 +651,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
+2
View File
@@ -2,3 +2,5 @@ services:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
import tseslint from 'typescript-eslint';
export default [
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/coverage/**',
'**/*.generated.*',
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
],
},
{
files: ['fluxer_app/src/**/*.tsx'],
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './fluxer_app/tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {'react-google-translate': reactGoogleTranslate},
rules: {
'react-google-translate/no-conditional-text-nodes-with-siblings': [
'error',
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
],
'react-google-translate/no-return-text-nodes': 'error',
},
},
];
+83 -1
View File
@@ -35,8 +35,9 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
}
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
let spec: openapiv3::OpenAPI =
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
@@ -54,6 +55,87 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
fs::write(&output_path, content).expect("failed to write generated API code");
}
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
let schemas = &spec
.components
.as_ref()
.expect("missing API components")
.schemas;
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
.expect("failed to inspect Error schema");
let mut throttled = serde_json::to_value(
schemas
.get("ThrottledError")
.expect("missing ThrottledError schema"),
)
.expect("failed to inspect ThrottledError schema");
assert_eq!(
error["additionalProperties"],
serde_json::json!({}),
"Progenitor error adaptation requires Error to retain all additional fields"
);
let properties = throttled["properties"]
.as_object_mut()
.expect("ThrottledError must be an object schema");
assert_eq!(
properties
.remove("retry_after")
.expect("missing retry_after")["type"],
"number"
);
assert_eq!(
properties.remove("global").expect("missing global")["type"],
"boolean"
);
assert_eq!(
throttled, error,
"ThrottledError must extend the common Error schema"
);
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
panic!("Progenitor error adaptation requires inline API paths");
};
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
let Some(response) = operation
.responses
.responses
.get_mut(&openapiv3::StatusCode::Code(429))
else {
continue;
};
let openapiv3::ReferenceOr::Item(response) = response else {
panic!("Progenitor error adaptation requires inline 429 responses");
};
let schema = &mut response
.content
.get_mut("application/json")
.expect("429 responses must return JSON")
.schema;
assert_eq!(
schema,
&Some(openapiv3::ReferenceOr::ref_(
"#/components/schemas/ThrottledError"
)),
"Progenitor only supports one error type per operation"
);
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
}
}
}
struct Face {
css_family: String,
weight: u64,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
@@ -35,7 +35,7 @@ impl AdminApiClient {
}
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
let key_id = snowflake(key_id);
self.generated()
.delete_admin_api_key(&key_id)
.await
+10 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
@@ -12,7 +12,7 @@ impl AdminApiClient {
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
include_attachments,
};
let response = self
.generated()
@@ -28,7 +28,7 @@ impl AdminApiClient {
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
include_attachments,
};
let response = self
.generated()
@@ -78,15 +78,17 @@ impl AdminApiClient {
subject_id: &str,
archive_id: &str,
) -> ApiResult<ArchiveDownloadUrlResponse> {
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.get_admin_archive_download(subject_type, subject_id, archive_id)
.get_admin_archive_download(
subject_type,
&snowflake(subject_id),
&snowflake(archive_id),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+2 -5
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
@@ -13,10 +13,7 @@ impl AdminApiClient {
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
let response = self
.generated()
.purge_admin_guild_assets(
&generated_types::SnowflakeType::from(guild_id.to_owned()),
&body,
)
.purge_admin_guild_assets(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+42 -63
View File
@@ -3,8 +3,6 @@
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
#[cfg(test)]
use super::types::AuditLogEntry;
use super::types::AuditLogsListResponse;
pub struct SearchAuditLogsParams {
@@ -38,67 +36,25 @@ impl AdminApiClient {
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
(
"q",
nonempty_string(params.query.as_deref()).unwrap_or_default(),
),
("q", params.query.as_deref().unwrap_or_default()),
(
"admin_user_id",
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
params.admin_user_id.as_deref().unwrap_or_default(),
),
(
"target_type",
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
params.target_type.as_deref().unwrap_or_default(),
),
(
"target_id",
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
),
("sort_by", sort_by.unwrap_or_default()),
("sort_order", sort_order.unwrap_or_default()),
("limit", limit),
("offset", offset),
("target_id", params.target_id.as_deref().unwrap_or_default()),
("sort_by", sort_by.as_deref().unwrap_or_default()),
("sort_order", sort_order.as_deref().unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
let query_params: Vec<(&str, &str)> = query_params
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect();
self.get("/admin/audit-logs", Some(&query_params)).await
}
}
#[cfg(test)]
fn audit_logs_response(
response: generated_types::AuditLogsListResponseSchema,
) -> ApiResult<AuditLogsListResponse> {
Ok(AuditLogsListResponse {
logs: response.logs.into_iter().map(audit_log_entry).collect(),
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
}
#[cfg(test)]
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
AuditLogEntry {
log_id: String::from(entry.log_id),
admin_user_id: String::from(entry.admin_user_id),
admin_user: None,
action: entry.action,
target_id: entry.target_id,
target_type: entry.target_type,
target_user: None,
target_guild: None,
target_channel: None,
related_users: Default::default(),
related_guilds: Default::default(),
related_channels: Default::default(),
audit_log_reason: entry.audit_log_reason,
metadata: entry.metadata,
created_at: entry.created_at,
}
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
@@ -113,12 +69,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -135,10 +85,39 @@ mod tests {
#[test]
fn rejects_lossy_audit_totals() {
let response = generated_types::AuditLogsListResponseSchema {
logs: Vec::new(),
total: 1.5,
};
assert!(audit_logs_response(response).is_err());
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
let response = serde_json::json!({"logs": [], "total": total});
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
}
}
#[test]
fn deserializes_audit_fields_without_losing_generated_string_values() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"admin_user": null,
"action": "USER_UPDATE",
"target_id": "345678901234567890",
"target_type": "user",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"audit_log_reason": "Account review",
"metadata": {"field": "username"},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let generated: generated_types::AuditLogsListResponseSchema =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), json);
}
}
+71 -46
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
@@ -9,10 +9,12 @@ impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_1: Some(generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}),
..Default::default()
},
)
.await
}
@@ -28,7 +30,10 @@ impl AdminApiClient {
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
)
.await
}
@@ -44,7 +49,10 @@ impl AdminApiClient {
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
suspicious_email_domain_request(domain)?.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
)
.await
}
@@ -62,10 +70,12 @@ impl AdminApiClient {
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_3: Some(generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}),
..Default::default()
},
)
.await
}
@@ -81,14 +91,16 @@ impl AdminApiClient {
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_4: Some(generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}),
..Default::default()
},
)
.await
}
@@ -104,15 +116,17 @@ impl AdminApiClient {
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
self.create_blocklist_entry(
"url-domain",
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: Some(match_subdomains),
notes: None,
severity: None,
source_url: None,
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_5: Some(generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains,
notes: None,
severity: None,
source_url: None,
}),
..Default::default()
},
)
.await
}
@@ -131,16 +145,17 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
let body = generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
},
);
}),
..Default::default()
};
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
@@ -186,15 +201,17 @@ impl AdminApiClient {
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"avatar-hash",
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_7: Some(generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}),
..Default::default()
},
)
.await
}
@@ -213,10 +230,7 @@ impl AdminApiClient {
let body = generated_types::BanUserAvatarRequest::default();
let response = self
.generated()
.ban_admin_user_avatar(
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
.ban_admin_user_avatar(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -225,7 +239,10 @@ impl AdminApiClient {
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
profile_substring_request(scope, substring)?.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
)
.await
}
@@ -249,6 +266,7 @@ impl AdminApiClient {
list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
self.generated()
.create_admin_blocklist_entry(list_type, &body)
.await
@@ -262,6 +280,7 @@ impl AdminApiClient {
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated()
.delete_admin_blocklist_entry(list_type, entry_value, scope)
@@ -276,6 +295,7 @@ impl AdminApiClient {
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<BanCheckResult> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self
.generated()
@@ -290,6 +310,11 @@ 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> {
generated_types::AdminBlocklistListType::try_from(list_type)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
format!(
"/admin/blocklists/{}/entries/{}",
+39 -66
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::BulkJobResponse;
@@ -13,15 +13,11 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
task:
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -33,14 +29,11 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -52,14 +45,11 @@ impl AdminApiClient {
remove_features: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -70,14 +60,10 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
guild_id: snowflake(guild_id),
task:
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -87,13 +73,9 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
task:
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -106,37 +88,28 @@ impl AdminApiClient {
public_reason: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect()
values.iter().map(|value| snowflake(value)).collect()
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
+109 -40
View File
@@ -120,7 +120,7 @@ impl AdminApiClient {
query_params: Option<&[(&str, &str)]>,
) -> ApiResult<T> {
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post<T: DeserializeOwned>(
@@ -152,7 +152,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post_with_reason<T: DeserializeOwned>(
@@ -164,7 +164,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
@@ -200,7 +200,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn patch_typed_with_reason<T, B>(
@@ -216,7 +216,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn put_with_reason<T: DeserializeOwned>(
@@ -228,7 +228,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn put_typed_with_reason<T, B>(
@@ -244,7 +244,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn put_void_with_reason(
@@ -284,22 +284,22 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
Self::check_response_status(response).await.map(drop)
}
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
if response.status().is_success() {
Ok(())
} else {
let status = response.status().as_u16();
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http {
status,
message: text,
})
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http { status, message })
}
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
@@ -328,28 +328,16 @@ impl AdminApiClient {
}
}
async fn parse_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> ApiResult<T> {
let status = response.status();
if status.as_u16() == 204 {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
}
if !status.is_success() {
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
return Err(ApiError::Http {
status: status.as_u16(),
message: text,
});
}
let text = response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
let response = Self::check_response_status(response).await?;
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
String::new()
} else {
response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?
};
if text.is_empty() {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
@@ -418,3 +406,84 @@ impl std::fmt::Display for ApiError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
.body(body)
.expect("valid response")
.into()
}
#[tokio::test]
async fn parses_successful_json_and_empty_responses() {
for (status, body, expected) in [
(200, r#"{"value":1}"#, json!({"value": 1})),
(201, "[1,2]", json!([1, 2])),
(202, "null", Value::Null),
(200, "", Value::Null),
(204, "ignored body", Value::Null),
] {
let actual: Value = AdminApiClient::parse_response(response(status, body))
.await
.expect("valid response body");
assert_eq!(actual, expected, "HTTP {status}: {body}");
}
}
#[tokio::test]
async fn empty_responses_preserve_null_deserialization_errors() {
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
.expect_err("null is not a list")
.to_string();
for (status, body) in [(200, ""), (204, "ignored body")] {
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
.await
.expect_err("missing list");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn malformed_json_preserves_deserialization_errors() {
for body in [" ", "{", "not JSON"] {
let expected = serde_json::from_str::<Value>(body)
.expect_err("malformed JSON")
.to_string();
let error = AdminApiClient::parse_response::<Value>(response(200, body))
.await
.expect_err("malformed response");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn void_responses_do_not_parse_successful_bodies() {
for status in [200, 201, 202, 204] {
AdminApiClient::parse_void_response(response(status, "not JSON"))
.await
.expect("successful void response");
}
}
#[tokio::test]
async fn typed_and_void_responses_preserve_http_errors() {
for status in [302, 400, 403, 404, 500] {
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
.await
.map(drop);
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
for result in [typed, empty] {
let error = result.expect_err("unsuccessful response");
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
}
}
}
}
}
+7 -6
View File
@@ -13,16 +13,17 @@ impl AdminApiClient {
duration_quantity: u32,
) -> ApiResult<CodesResponse> {
let body = generated_types::GenerateGiftCodesRequest {
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
count: crate::api::generated::nonzero_u32(count, "count")
.map_err(ApiError::Parse)?
.into(),
duration_quantity: crate::api::generated::nonzero_u32(
duration_quantity,
"duration_quantity",
)
.map_err(ApiError::Parse)?,
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
duration_type,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(ApiError::Parse)?
.into(),
duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
+16 -28
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -16,8 +16,7 @@ impl AdminApiClient {
.list_admin_discovery_applications()
.await
.map_err(|e| self.generated_error(e))?;
response
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(pending_discovery_application)
.collect()
@@ -29,8 +28,7 @@ impl AdminApiClient {
.list_admin_discovery_listings()
.await
.map_err(|e| self.generated_error(e))?;
response
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(listed_guild)
.collect()
@@ -41,16 +39,13 @@ impl AdminApiClient {
guild_id: &str,
reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
reason: reason
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
},
);
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
reason: reason
.map(generated_types::DiscoveryReviewReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.update_admin_discovery_application(&guild_id, &body)
@@ -64,18 +59,11 @@ impl AdminApiClient {
guild_id: &str,
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
reason:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
reason,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
status:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
},
);
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.update_admin_discovery_application(&guild_id, &body)
@@ -89,7 +77,7 @@ impl AdminApiClient {
guild_id: &str,
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminRemoveRequest {
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
+6 -2
View File
@@ -16,6 +16,10 @@ pub use inner::types;
pub use inner::Client as GeneratedClient;
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
types::SnowflakeType::Variant0(value.to_owned())
}
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
@@ -126,10 +130,10 @@ mod tests {
let response: SearchGuildsResponse =
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
assert_eq!(response.total as i64, 1);
assert_eq!(response.total, 1.0);
assert_eq!(response.guilds.len(), 1);
assert_eq!(response.guilds[0].name, "Test Guild");
assert_eq!(response.guilds[0].member_count, 42);
assert_eq!(*response.guilds[0].member_count, 42);
}
#[test]
+3 -3
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
impl AdminApiClient {
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.list_admin_guild_emojis(&guild_id)
@@ -20,7 +20,7 @@ impl AdminApiClient {
&self,
guild_id: &str,
) -> ApiResult<ListGuildStickersResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.list_admin_guild_stickers(&guild_id)
+23 -42
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use serde::Deserialize;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::reports::SearchReportsParams;
use super::types::{
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
@@ -28,13 +29,8 @@ impl AdminApiClient {
}
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
let response = self
.generated()
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
resp.guild
self.lookup_guild(guild_id)
.await?
.map(GuildInfo::from)
.ok_or_else(|| super::client::ApiError::Http {
status: 404,
@@ -144,8 +140,7 @@ impl AdminApiClient {
let limit = limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from);
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.list_admin_guild_audit_logs(
@@ -153,7 +148,7 @@ impl AdminApiClient {
None,
None,
before.as_ref(),
limit.as_ref(),
limit,
None,
)
.await
@@ -259,37 +254,27 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
None,
None,
Some(guild_id),
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reported_guild_id: Some(guild_id),
limit,
offset,
)
..Default::default()
})
.await
}
}
#[derive(Deserialize)]
struct GuildSettingsPatch {
content_warning_level: Option<generated_types::ContentWarningLevel>,
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
content_warning_text: Option<String>,
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
disabled_operations: Option<generated_types::GuildOperations>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
mfa_level: Option<generated_types::GuildMfaLevel>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
mfa_level: Option<generated_types::GuildMfaLevelInput>,
nsfw: Option<bool>,
nsfw_level: Option<generated_types::NsfwLevel>,
verification_level: Option<generated_types::GuildVerificationLevel>,
nsfw_level: Option<generated_types::NsfwLevelInput>,
verification_level: Option<generated_types::GuildVerificationLevelInput>,
}
fn search_guilds_response(
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
owner_global_name: response.owner_global_name,
owner_discriminator: response.owner_discriminator,
member_count: crate::api::generated::i64_to_u64(
i64::from(response.member_count),
i64::from(i32::from(response.member_count)),
"member_count",
)
.map_err(ApiError::Parse)?,
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
nsfw_level: response.nsfw_level.map(i32::from),
nsfw: response.nsfw,
content_warning_level: response.content_warning_level.map(i32::from),
content_warning_text: response.content_warning_text,
content_warning_text: response.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
})
@@ -338,9 +323,9 @@ fn guild_update_response(
Ok(GuildUpdateResponse {
guild: GuildInfo {
id: String::from(guild.id),
name: guild.name,
icon: guild.icon,
banner: guild.banner,
name: String::from(guild.name),
icon: guild.icon.map(String::from),
banner: guild.banner.map(String::from),
owner_id: String::from(guild.owner_id),
owner_username: None,
owner_global_name: None,
@@ -350,11 +335,11 @@ fn guild_update_response(
"member_count",
)
.map_err(ApiError::Parse)?,
features: guild.features,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from),
content_warning_text: guild.content_warning_text,
content_warning_text: guild.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
},
@@ -380,10 +365,6 @@ fn guild_settings_request(
})
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
+3 -1
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
@@ -39,7 +41,7 @@ impl AdminApiClient {
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
let response = self
.generated()
.get_admin_job(job_id)
.get_admin_job(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+7 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -43,7 +43,8 @@ impl AdminApiClient {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
confirmed_viewed: true,
filename: filename.to_owned(),
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?,
message_id: snowflake(message_id),
reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
@@ -119,7 +120,7 @@ impl AdminApiClient {
) -> ApiResult<MessageShredStatusResponse> {
let response = self
.generated()
.get_admin_message_shred(job_id)
.get_admin_message_shred(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -133,13 +134,15 @@ impl AdminApiClient {
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let context_limit = context_limit.to_string();
let filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.search_admin_messages(
Some(&snowflake(attachment_id)),
&snowflake(channel_id),
Some(context_limit.as_str()),
Some(filename),
Some(&filename),
None,
None,
None,
@@ -195,7 +198,3 @@ impl AdminApiClient {
self.generated_value(response.into_inner())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+120 -58
View File
@@ -1,10 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
};
#[derive(Default)]
pub struct SearchReportsParams<'a> {
pub query: Option<&'a str>,
pub status: Option<i32>,
pub report_type: Option<i32>,
pub category: Option<&'a str>,
pub reporter_id: Option<&'a str>,
pub reported_user_id: Option<&'a str>,
pub reported_guild_id: Option<&'a str>,
pub reported_channel_id: Option<&'a str>,
pub guild_context_id: Option<&'a str>,
pub resolved_by_admin_id: Option<&'a str>,
pub sort_by: Option<&'a str>,
pub sort_order: Option<&'a str>,
pub limit: u32,
pub offset: u32,
}
impl AdminApiClient {
pub async fn list_reports(
&self,
@@ -26,7 +46,7 @@ impl AdminApiClient {
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self
.generated()
.get_admin_report(report_id)
.get_admin_report(&snowflake(report_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -50,51 +70,55 @@ impl AdminApiClient {
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn search_reports(
&self,
query: Option<&str>,
status: Option<i32>,
report_type: Option<i32>,
category: Option<&str>,
reporter_id: Option<&str>,
reported_user_id: Option<&str>,
reported_guild_id: Option<&str>,
reported_channel_id: Option<&str>,
guild_context_id: Option<&str>,
resolved_by_admin_id: Option<&str>,
sort_by: Option<&str>,
sort_order: Option<&str>,
limit: u32,
offset: u32,
params: &SearchReportsParams<'_>,
) -> ApiResult<SearchReportsResponse> {
let status = status.map(report_status).transpose()?.unwrap_or_default();
let report_type = report_type
let status = params
.status
.map(report_status)
.transpose()?
.unwrap_or_default();
let report_type = params
.report_type
.map(report_type_name)
.transpose()?
.unwrap_or_default();
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.to_string();
let sort_by = params
.sort_by
.map(report_sort_by)
.transpose()?
.unwrap_or_default();
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
("q", query.unwrap_or_default()),
("q", params.query.unwrap_or_default()),
("status", status),
("report_type", report_type),
("category", category.unwrap_or_default()),
("reporter_id", reporter_id.unwrap_or_default()),
("reported_user_id", reported_user_id.unwrap_or_default()),
("reported_guild_id", reported_guild_id.unwrap_or_default()),
("category", params.category.unwrap_or_default()),
("reporter_id", params.reporter_id.unwrap_or_default()),
(
"reported_user_id",
params.reported_user_id.unwrap_or_default(),
),
(
"reported_guild_id",
params.reported_guild_id.unwrap_or_default(),
),
(
"reported_channel_id",
reported_channel_id.unwrap_or_default(),
params.reported_channel_id.unwrap_or_default(),
),
(
"guild_context_id",
params.guild_context_id.unwrap_or_default(),
),
("guild_context_id", guild_context_id.unwrap_or_default()),
(
"resolved_by_admin_id",
resolved_by_admin_id.unwrap_or_default(),
params.resolved_by_admin_id.unwrap_or_default(),
),
("sort_by", sort_by),
("sort_order", sort_order.unwrap_or_default()),
("sort_order", params.sort_order.unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
@@ -107,22 +131,12 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
Some(reporter_id),
None,
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reporter_id: Some(reporter_id),
limit,
offset,
)
..Default::default()
})
.await
}
@@ -132,22 +146,12 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
None,
Some(reported_user_id),
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reported_user_id: Some(reported_user_id),
limit,
offset,
)
..Default::default()
})
.await
}
}
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_statuses_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "pending"), (1, "resolved")] {
assert_eq!(report_status(value).expect("supported status"), expected);
}
for value in [-1, 2] {
assert_eq!(
report_status(value)
.expect_err("unknown status")
.to_string(),
format!("parse error: unknown report status: {value}")
);
}
}
#[test]
fn report_types_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
assert_eq!(report_type_name(value).expect("supported type"), expected);
}
for value in [-1, 3] {
assert_eq!(
report_type_name(value)
.expect_err("unknown type")
.to_string(),
format!("parse error: unknown report type: {value}")
);
}
}
#[test]
fn report_sort_fields_accept_only_the_existing_aliases() {
for (field, expected) in [
("createdAt", "created_at"),
("created_at", "created_at"),
("reportedAt", "reported_at"),
("reported_at", "reported_at"),
("resolvedAt", "resolved_at"),
("resolved_at", "resolved_at"),
] {
assert_eq!(
report_sort_by(field).expect("supported sort field"),
expected
);
}
assert_eq!(
report_sort_by("unknown")
.expect_err("unknown sort field")
.to_string(),
"parse error: unknown report sort field: unknown"
);
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
@@ -11,8 +11,11 @@ impl AdminApiClient {
index_type: &str,
guild_id: Option<&str>,
) -> ApiResult<RefreshSearchIndexResponse> {
let index_type =
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
guild_id: guild_id.map(snowflake),
user_id: None,
};
let response = self
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
use super::types::{
@@ -24,11 +24,7 @@ impl AdminApiClient {
guild_ids: &[String],
) -> ApiResult<ReloadAllGuildsResponse> {
let body = generated_types::ReloadGuildsRequest {
guild_ids: guild_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::SendSystemDmResponse;
@@ -14,11 +14,7 @@ impl AdminApiClient {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
pub sso: SsoConfigResponse,
@@ -18,6 +20,10 @@ pub struct InstanceConfigResponse {
pub integrations: InstanceIntegrationsResponse,
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -436,6 +442,125 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
pub const ALL: [Self; 7] = [
Self::None,
Self::Standard,
Self::Gate,
Self::Speex,
Self::Rnnoise,
Self::Gtcrn,
Self::DeepFilter,
];
pub fn label(&self) -> &'static str {
match self {
Self::None => "None (pass-through)",
Self::Standard => "Standard (WebRTC)",
Self::Gate => "Noise gate",
Self::Speex => "Speex",
Self::Rnnoise => "RNNoise",
Self::Gtcrn => "GTCRN",
Self::DeepFilter => "DeepFilterNet",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
default_backend: NoiseSuppressionBackend::Standard,
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
allow_user_override: true,
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
pub poll_interval_seconds: u64,
pub poll_jitter_percent: u32,
}
impl Default for ExperimentDeliveryConfigResponse {
fn default() -> Self {
Self {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ExperimentDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_jitter_percent: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceRegistrationResponse {
pub mode: RegistrationMode,
@@ -525,6 +650,10 @@ pub struct InstanceConfigUpdateRequest {
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -826,3 +955,89 @@ pub struct CreateRegistrationUrlResponse {
pub code: String,
pub url: String,
}
#[cfg(test)]
mod tests {
use super::*;
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 delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise 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_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
"{name}.{field}"
);
}
}
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+1
View File
@@ -28,6 +28,7 @@ pub struct VoiceServer {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub is_active: Option<bool>,
pub soft_connection_limit: Option<i64>,
pub vip_only: Option<bool>,
#[serde(default)]
pub required_guild_features: Vec<String>,
+14 -18
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -362,11 +362,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUsernameUpdateRequest {
discriminator: discriminator
.map(generated_types::DiscriminatorType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
username: generated_types::UsernameType::try_from(username)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
username: generated_types::UsernameType::from(username.to_owned()),
};
let response = self
.generated()
@@ -399,7 +396,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
@@ -430,10 +428,12 @@ impl AdminApiClient {
days_until_deletion: u32,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
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)?,
@@ -484,10 +484,10 @@ impl AdminApiClient {
target_id: &str,
category: &str,
) -> ApiResult<()> {
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated()
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -498,7 +498,7 @@ impl AdminApiClient {
user_id: &str,
category: &str,
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
@@ -565,10 +565,6 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
values
.iter()
+108 -21
View File
@@ -55,15 +55,14 @@ impl AdminApiClient {
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = voice_request_body(params, &["id"])?;
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
@@ -103,9 +102,10 @@ impl AdminApiClient {
) -> ApiResult<CreateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
voice_request_body(params, &["region_id"])?,
)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_admin_voice_server(&region_id, &body)
@@ -121,15 +121,18 @@ impl AdminApiClient {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = voice_request_body(params, &["region_id", "server_id"])?;
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -150,6 +153,26 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn voice_request_body(
params: &serde_json::Value,
path_fields: &[&str],
) -> ApiResult<serde_json::Value> {
let mut body = params
.as_object()
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
.clone();
for field in path_fields {
body.remove(*field);
}
Ok(body.into())
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
@@ -168,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
.map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn region_update_body_preserves_explicit_restriction_clears() {
let expected = json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
});
let mut params = expected.clone();
params["id"] = json!("eu");
let body = voice_request_body(&params, &["id"]).expect("region body");
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
.expect("valid region body");
assert_eq!(body, expected);
}
#[test]
fn server_update_body_preserves_clears_and_omitted_restrictions() {
for expected in [
json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"soft_connection_limit": null,
"latitude": null,
"longitude": null,
}),
json!({"is_active": false}),
] {
let mut params = expected.clone();
params["region_id"] = json!("eu");
params["server_id"] = json!("primary");
let body =
voice_request_body(&params, &["region_id", "server_id"]).expect("server body");
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
}
}
#[test]
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
let expected = json!({
"server_id": "primary",
"endpoint": "wss://voice.example.com",
"api_key": "key",
"api_secret": "secret",
});
let mut params = expected.clone();
params["region_id"] = json!("eu");
let body = voice_request_body(&params, &["region_id"]).expect("server body");
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
assert!(
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
);
}
}
+7 -1
View File
@@ -129,6 +129,11 @@ impl AdminConfig {
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
pub fn admin_origin(&self) -> Option<String> {
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
origin.is_tuple().then(|| origin.ascii_serialization())
}
}
impl RuntimeEnv {
@@ -202,6 +207,7 @@ mod tests {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
@@ -350,7 +356,7 @@ mod tests {
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
+95 -4
View File
@@ -27,7 +27,6 @@ pub async fn csrf_protection(
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let secure_cookies = config.secure_cookies();
let user_id = request
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
@@ -167,7 +166,7 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
@@ -180,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
None => true,
}
}
@@ -275,6 +274,98 @@ mod tests {
);
}
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let issued = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
let cookie = issued
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.filter_map(|value| value.split(';').next())
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
.expect("a csrf cookie is issued")
.to_owned();
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/")
.header(header::COOKIE, cookie.as_str())
.header(header::ORIGIN, origin)
.header(CSRF_HEADER_NAME, token.as_str())
.body(Body::empty())
.unwrap(),
)
.await
.expect("router responds");
response.status()
}
#[tokio::test]
async fn a_matching_origin_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_foreign_origin_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://evil.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_unparseable_admin_endpoint_fails_closed() {
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_explicit_default_port_matches_a_portless_origin() {
let status = action_status(
"https://admin.example.test:443/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[test]
fn oauth2_callback_is_exempt() {
let exempt = IGNORED_PATH_SUFFIXES
+16 -13
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::{AdminApiClient, ApiResultExt},
api::{
client::{AdminApiClient, ApiResultExt},
reports::SearchReportsParams,
},
config::AdminConfig,
middleware::{
auth::AuthContext,
@@ -89,22 +92,22 @@ async fn reports_list(
.as_deref()
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(
search_query.as_deref(),
.search_reports(&SearchReportsParams {
query: search_query.as_deref(),
status,
report_type,
query.category.as_deref(),
query.reporter_id.as_deref(),
query.reported_user_id.as_deref(),
query.reported_guild_id.as_deref(),
query.reported_channel_id.as_deref(),
query.guild_context_id.as_deref(),
query.resolved_by_admin_id.as_deref(),
Some(sort_by),
Some(sort_order),
category: query.category.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort_by: Some(sort_by),
sort_order: Some(sort_order),
limit,
offset,
)
})
.await
.log_error("search reports");
+623 -63
View File
@@ -7,7 +7,8 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
@@ -16,8 +17,10 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
VoiceE2eeScope,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -200,6 +203,14 @@ pub async fn instance_config_post(
let update = build_media_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"test_smtp" => match build_smtp_test_request(&form) {
Ok(request) => match client.test_instance_smtp_config(&request).await {
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
@@ -401,12 +412,7 @@ fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
allowed_domains: Some(allowed),
redirect_uri: None,
}),
gateway_rollout: None,
registration: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
@@ -437,15 +443,216 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
.parse_u64("gateway_rollout_max_concurrent_guild_starts"),
voice_e2ee_scope,
}),
sso: None,
registration: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
fn parse_form_number<T>(
form: &MultiValueForm,
key: &str,
label: &str,
min: T,
max: T,
) -> Result<Option<T>, String>
where
T: std::str::FromStr + Ord + std::fmt::Display,
{
let Some(raw) = form.first(key) else {
return Ok(None);
};
let invalid = || format!("{label} must be a whole number between {min} and {max}");
let value = raw.trim().parse::<T>().map_err(|_| invalid())?;
if value < min || value > max {
return Err(invalid());
}
Ok(Some(value))
}
fn parse_voice_noise_suppression_rollout_salt(
form: &MultiValueForm,
) -> Result<Option<String>, String> {
let Some(raw) = form.first("voice_ns_rollout_salt") else {
return Ok(None);
};
let salt = raw.trim();
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
return Err(format!(
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
let mut ids: Vec<String> = Vec::new();
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
if !is_voice_noise_suppression_snowflake(candidate) {
return Err(format!(
"{label} entry {} must contain 1 to 20 decimal digits",
index + 1
));
}
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
}
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_voice_noise_suppression_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
}
let backend = backend.trim().parse().map_err(|_| {
format!("Guild overrides line {line_number} must name a supported backend")
})?;
if let Some(existing) = overrides
.iter()
.find(|existing| existing.guild_id == guild_id)
{
if existing.backend != backend {
return Err(format!(
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
));
}
continue;
}
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
return Err(format!(
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
));
}
overrides.push(VoiceNoiseSuppressionGuildOverride {
guild_id: guild_id.to_owned(),
backend,
});
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let selected: Vec<NoiseSuppressionBackend> = form
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
.into_iter()
.map(|value| {
value.parse().map_err(|_| {
"Enabled backends must name supported noise suppression backends".to_owned()
})
})
.collect::<Result<_, _>>()?;
let enabled_backends = NoiseSuppressionBackend::ALL
.into_iter()
.filter(|backend| selected.contains(backend))
.collect();
Ok(InstanceConfigUpdateRequest {
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
enabled: Some(form.bool_value("voice_ns_enabled")),
default_backend: form
.first("voice_ns_default_backend")
.map(|value| {
value.parse().map_err(|_| {
"Default backend must name a supported noise suppression backend".to_owned()
})
})
.transpose()?,
enabled_backends: Some(enabled_backends),
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
)?,
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
poll_interval_seconds: parse_form_number(
form,
"experiment_delivery_poll_interval_seconds",
"Poll interval",
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
)?,
poll_jitter_percent: parse_form_number(
form,
"experiment_delivery_poll_jitter_percent",
"Poll jitter",
0,
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
)?,
}),
..Default::default()
})
}
fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let mode = match form.first("registration_mode") {
Some("approval") => Some(RegistrationMode::Approval),
@@ -454,27 +661,19 @@ fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
_ => None,
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: Some(InstanceRegistrationConfigUpdateRequest {
mode,
admin_registration_urls_enabled: Some(
form.bool_value("admin_registration_urls_enabled"),
),
}),
sso: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let optional = |key: &str| Some(form.clean(key));
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: Some(AppBrandingConfigUpdateRequest {
product_name: form.clean("app_product_name"),
@@ -491,18 +690,13 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
legal: None,
registration: None,
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let optional = |key: &str| Some(form.clean(key));
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: None,
setup: None,
@@ -512,17 +706,12 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
}),
registration: None,
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: None,
setup: None,
@@ -531,9 +720,7 @@ fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateR
collect_date_of_birth: Some(form.bool_value("app_collect_date_of_birth")),
}),
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
@@ -549,10 +736,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let services = build_services_update(form);
let deferred_phone_gate = build_deferred_phone_gate_update(form);
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: None,
single_community_name: None,
@@ -561,8 +744,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
services,
deferred_phone_gate,
}),
integrations: None,
media: None,
..Default::default()
}
}
@@ -626,11 +808,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
_ => None,
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: None,
integrations: Some(InstanceIntegrationsUpdateRequest {
gif: Some(InstanceGifIntegrationUpdateRequest {
klipy_api_key: clean("integration_klipy_api_key"),
@@ -671,7 +848,7 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
keys: bluesky_keys,
}),
}),
media: None,
..Default::default()
}
}
@@ -681,12 +858,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
.and_then(|value| value.trim().parse::<f64>().ok())
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: None,
integrations: None,
media: Some(InstanceMediaUpdateRequest {
attachment_decay: Some(InstanceAttachmentDecayUpdateRequest {
enabled: Some(form.bool_value("media_attachment_decay_enabled")),
@@ -700,6 +871,7 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
}),
}),
..Default::default()
}
}
@@ -728,10 +900,6 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
@@ -740,8 +908,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
services: None,
deferred_phone_gate: None,
}),
integrations: None,
media: None,
..Default::default()
}
}
@@ -1066,6 +1233,399 @@ 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_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
assert_eq!(
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
"2".to_owned(),
"3".to_owned(),
"4".to_owned(),
"5".to_owned()
]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
.expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
"-1",
"1.0",
"999999999999999999999",
"<script>",
] {
assert_eq!(
parse_voice_noise_suppression_user_ids(
&format!("123,{value}"),
"Included user IDs"
)
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
}
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids =
parse_voice_noise_suppression_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.last(), Some(&"999".to_owned()));
assert_eq!(
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
for (line, message) in [
("456", "Guild overrides line 3 must use guild_id=backend"),
(
"=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"not-a-guild=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"999999999999999999999=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"456=unknown_backend",
"Guild overrides line 3 must name a supported backend",
),
(
"456=",
"Guild overrides line 3 must name a supported backend",
),
(
"123=gate",
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
),
] {
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
.expect_err("invalid guild rule"),
message,
"{line}"
);
}
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
for (key, message, above_max) in [
(
"voice_ns_rollout_basis_points",
"Rollout basis points must be a whole number between 0 and 10000",
"10001",
),
(
"voice_ns_suppression_strength",
"Suppression strength must be a whole number between 0 and 100",
"101",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_basis_points, Some(250));
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
for salt in [
String::new(),
" ".to_owned(),
"é".repeat(65),
"🎲".repeat(33),
] {
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
"Rollout salt must be between 1 and 64 characters"
);
}
}
#[test]
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
let form =
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_salt, Some(salt));
}
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
let overrides = parse_voice_noise_suppression_guild_overrides(
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
).expect("valid guild rules");
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_experiment_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"experiment_delivery": {}})
);
}
#[test]
fn build_experiment_delivery_update_rejects_invalid_numbers() {
for (key, message, below_min, above_max) in [
(
"experiment_delivery_poll_interval_seconds",
"Poll interval must be a whole number between 60 and 86400",
"59",
"86401",
),
(
"experiment_delivery_poll_jitter_percent",
"Poll jitter must be a whole number between 0 and 50",
"-1",
"51",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
below_min,
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_experiment_delivery_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
for (interval, jitter) in [(60, 0), (86_400, 50)] {
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
let request = build_experiment_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
);
}
}
#[test]
fn build_experiment_delivery_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
);
let update = build_experiment_delivery_update(&form)
.expect("valid form")
.experiment_delivery
.expect("experiment delivery update");
assert_eq!(update.poll_interval_seconds, Some(900));
assert_eq!(update.poll_jitter_percent, Some(25));
}
#[test]
fn update_limit_rule_values_reads_checked_limit_keys() {
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
+48 -16
View File
@@ -4,7 +4,7 @@ use crate::{
api::client::AdminApiClient,
middleware::{auth::AuthContext, csrf},
state::AppState,
templates,
templates::{self, pages::voice_servers::VoiceServersPageParams},
};
use axum::{
Router,
@@ -13,12 +13,34 @@ use axum::{
routing::get,
};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
struct VoiceServersQuery {
region_id: Option<String>,
}
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
let response = match client.get_gateway_voice_state_counts().await {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "admin API request failed: load voice state counts");
return HashMap::new();
}
};
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
return HashMap::new();
};
servers
.iter()
.filter_map(|entry| {
let server_id = entry.get("server_id")?.as_str()?.to_owned();
let count = entry.get("voice_state_count")?.as_i64()?;
Some((server_id, count))
})
.collect()
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
@@ -79,17 +101,21 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
None,
None,
None,
None,
&csrf_token,
&VoiceServersPageParams {
region_id: None,
region_name: None,
servers: None,
connection_counts: &HashMap::new(),
error: None,
csrf_token: &csrf_token,
},
);
return Html(markup.into_string()).into_response();
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let connection_counts = load_server_connection_counts(&client).await;
let region_name = match client.get_voice_region(region_id, false).await {
Ok(resp) => resp
@@ -107,11 +133,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
Some(&resp.servers),
None,
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: Some(&resp.servers),
connection_counts: &connection_counts,
error: None,
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
@@ -120,11 +149,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
None,
Some(&msg),
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: None,
connection_counts: &connection_counts,
error: Some(&msg),
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
+109 -20
View File
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
}
body.insert("is_default".into(), form.bool_value("is_default").into());
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
fn insert_submitted_list(
body: &mut serde_json::Map<String, serde_json::Value>,
form: &MultiValueForm,
field: &str,
) {
let repeated = format!("{field}[]");
if !form.contains_key(&repeated) && !form.contains_key(field) {
return;
}
body.insert(
field.to_owned(),
form.list_values_any(&[repeated.as_str(), field]).into(),
);
}
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(v) = form.clean("region_id") {
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
body.insert("longitude".into(), lng.into());
}
body.insert("is_active".into(), form.bool_value("is_active").into());
if let Some(raw) = form.first("soft_connection_limit") {
let trimmed = raw.trim();
if trimmed.is_empty() {
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
} else if let Ok(limit) = trimmed.parse::<i64>()
&& limit > 0
{
body.insert("soft_connection_limit".into(), limit.into());
}
}
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
@@ -255,6 +264,86 @@ mod tests {
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
}
#[test]
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
);
let body = build_server_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
);
let body = build_server_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], serde_json::json!(false));
}
#[test]
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
let body = build_region_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
let body = build_region_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
}
#[test]
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
let form =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
}
#[test]
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
}
#[test]
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
assert!(
!build_server_body(&absent)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let invalid =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
assert!(
!build_server_body(&invalid)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
assert!(
!build_server_body(&zero)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
}
#[test]
fn build_server_body_preserves_single_and_repeated_values() {
let form = MultiValueForm::parse(
@@ -0,0 +1,236 @@
use std::cmp::Ordering;
use serde_json::Value;
#[derive(Debug, PartialEq)]
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
#[derive(Debug, PartialEq)]
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value["attachments"]
.as_array()
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value_id(&value["id"]).unwrap_or_default(),
content: value["content"].as_str().unwrap_or("").to_owned(),
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
author_id: value_id(&value["author_id"]).unwrap_or_default(),
author_username: value["author_username"]
.as_str()
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
author_discriminator: value_id(&value["author_discriminator"])
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
channel_nsfw: value["channel_nsfw"].as_bool(),
channel_content_warning_level: value["channel_content_warning_level"]
.as_i64()
.map(|n| n as i32),
channel_content_warning_text: value["channel_content_warning_text"]
.as_str()
.map(ToOwned::to_owned),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value_id(&value["id"]).unwrap_or_default(),
url: value["url"].as_str().unwrap_or("").to_owned(),
filename: value["filename"].as_str().unwrap_or("").to_owned(),
nsfw: value["nsfw"].as_bool(),
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
width: value["width"].as_u64().map(|n| n as u32),
height: value["height"].as_u64().map(|n| n as u32),
size: value["size"].as_u64(),
ncmec_status: value["ncmec_status"]
.as_str()
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
ncmec_failure_reason: value["ncmec_failure_reason"]
.as_str()
.map(ToOwned::to_owned),
}
}
pub(crate) fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_message_and_attachment_fields() {
let value = json!({
"id": "9007199254740993",
"content": "Message content",
"timestamp": "2026-09-11T12:00:00Z",
"author_id": 42,
"author_username": "alice",
"author_global_name": "Alice",
"author_discriminator": 1234,
"author_avatar": "avatar-hash",
"channel_id": "100",
"channel_nsfw": false,
"channel_content_warning_level": 2,
"channel_content_warning_text": "Content warning",
"guild_nsfw": true,
"attachments": [{
"id": 9007199254740993_u64,
"url": "https://cdn.example.com/image.png",
"filename": "image.png",
"nsfw": true,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096,
"ncmec_status": "submitted",
"ncmec_report_id": "report-id",
"ncmec_failure_reason": "previous failure"
}]
});
assert_eq!(
message_from_value(&value),
Message {
id: "9007199254740993".into(),
content: "Message content".into(),
timestamp: "2026-09-11T12:00:00Z".into(),
author_id: "42".into(),
author_username: "alice".into(),
author_global_name: Some("Alice".into()),
author_discriminator: "1234".into(),
author_avatar: Some("avatar-hash".into()),
channel_id: "100".into(),
channel_nsfw: Some(false),
channel_content_warning_level: Some(2),
channel_content_warning_text: Some("Content warning".into()),
guild_nsfw: Some(true),
attachments: vec![Attachment {
id: "9007199254740993".into(),
url: "https://cdn.example.com/image.png".into(),
filename: "image.png".into(),
nsfw: Some(true),
content_type: Some("image/png".into()),
width: Some(640),
height: Some(480),
size: Some(4096),
ncmec_status: "submitted".into(),
ncmec_report_id: Some("report-id".into()),
ncmec_failure_reason: Some("previous failure".into()),
}],
}
);
}
#[test]
fn retains_display_defaults_for_incomplete_snapshots() {
let message = message_from_value(&json!({"attachments": [{}]}));
assert_eq!(message.author_username, "Unknown");
assert_eq!(message.author_discriminator, "0000");
assert_eq!(message.content, "");
assert_eq!(message.author_global_name, None);
assert_eq!(message.channel_nsfw, None);
assert_eq!(
message.attachments,
vec![Attachment {
id: String::new(),
url: String::new(),
filename: String::new(),
nsfw: None,
content_type: None,
width: None,
height: None,
size: None,
ncmec_status: "not_submitted".into(),
ncmec_report_id: None,
ncmec_failure_reason: None,
}]
);
}
#[test]
fn orders_string_and_numeric_snowflakes_without_rounding() {
let values = vec![
json!({"id": "9007199254740993"}),
json!({"id": "10"}),
json!({"id": 2}),
json!({"id": 9007199254740992_u64}),
];
let messages = ordered_messages(&values);
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
assert_eq!(values[0]["id"], "9007199254740993");
assert!(ordered_messages(&[]).is_empty());
}
#[test]
fn preserves_message_order_when_ids_are_equal() {
let values = [
json!({"id": "10", "content": "first"}),
json!({"id": 10, "content": "second"}),
];
let messages = ordered_messages(&values);
assert_eq!(messages[0].content, "first");
assert_eq!(messages[1].content, "second");
}
}
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
use super::message_data::{Attachment, Message};
fn is_image(att: &Attachment) -> bool {
att.content_type
@@ -13,6 +13,7 @@ pub mod error_display;
pub mod form;
pub mod icons;
pub mod media;
pub mod message_data;
pub mod message_list;
pub mod nsfw_indicators;
pub mod page_container;
@@ -147,9 +147,14 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
@@ -175,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
"VERY_LARGE_GUILD",
];
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn guild_feature_label(feature: &str) -> String {
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
format!("{feature} (deprecated, removal only)")
} else {
feature.to_owned()
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
let base = &config.base_path;
let admin_acls = auth
@@ -219,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
}
}
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@for feature in GUILD_FEATURES {
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
}
}
}
}
}
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@@ -301,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Add"
}
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
(guild_feature_checkbox_grid("add_features[]", false))
}
div {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Remove"
}
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES))
(guild_feature_checkbox_grid("remove_features[]", true))
}
(form_field_group("Custom features to add", "custom_add_features", false, None,
Some("Comma-separated list of custom features not in the standard set."),
@@ -408,3 +435,26 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_grid_offers_only_the_opt_in_clone_features() {
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
}
}
@@ -14,9 +14,14 @@ use maud::{Markup, html};
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn feature_label(feature: &str) -> String {
if DEPRECATED_FEATURES.contains(&feature) {
format!("{feature} (deprecated, no longer enforced)")
} else {
feature.to_owned()
}
}
pub fn features_tab(
config: &AdminConfig,
guild: &GuildInfo,
@@ -85,7 +100,7 @@ pub fn features_tab(
(checkbox(
"features[]",
feature,
feature,
&feature_label(feature),
guild.features.iter().any(|f| f == feature),
true,
))
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
@for feature in &enabled {
span class="inline-flex items-center rounded-full bg-green-100 \
px-2.5 py-0.5 text-xs font-medium text-green-800" {
(feature)
(feature_label(feature))
}
}
@for feature in &custom {
@@ -166,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
.copied()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn guild_with_features(features: &[&str]) -> GuildInfo {
serde_json::from_value(serde_json::json!({
"id": "1600000000000000001",
"name": "Test Guild",
"icon": null,
"banner": null,
"owner_id": "1500000000000000001",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"features": features,
"nsfw_level": null,
"nsfw": null,
"content_warning_level": null,
"content_warning_text": null,
"description": null,
"vanity_url_code": null,
}))
.expect("guild fixture")
}
#[test]
fn editor_offers_the_opt_in_clone_features() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
}
#[test]
fn editor_keeps_the_deprecated_clone_features_clearable() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
assert_eq!(
feature_label("CLONE_EMOJI_DISABLED"),
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
);
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
}
#[test]
fn readonly_view_marks_a_stale_flag_as_deprecated() {
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
assert!(markup.contains("CLONE_STICKER_ENABLED"));
}
}
@@ -2,10 +2,12 @@
use crate::{
api::types::{
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
RegistrationUrlResponse, SsoConfigResponse,
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -42,6 +44,17 @@ fn format_decimal(value: f64) -> String {
}
}
fn entry_count_hint(count: usize, cap: usize) -> Markup {
html! {
p class="text-xs text-neutral-500" {
(count) " of " (cap) " stored"
@if count >= cap {
" (at the cap; remove an entry before adding another)"
}
}
}
}
fn number_field(
name: &str,
label: &str,
@@ -134,6 +147,8 @@ 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))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
} @else {
@@ -953,6 +968,254 @@ fn gateway_rollout_section(
)
}
fn voice_noise_suppression_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
section_card_with_description(
"Voice Noise Suppression",
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
of them are targeted. While the master switch below is off nothing on this form reaches \
any client: every user keeps the audio pipeline they have today, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout, targeting \
and override fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
(checkbox(
"voice_ns_allow_user_override",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_ns_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"voice_ns_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
VOICE_NS_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."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
))
p class="text-xs text-neutral-500" {
"One per line as guild_id=backend. A guild \
rule targets callers even outside the canary. Always-on user rules \
take precedence, and excluded users stay off. Invalid lines and \
conflicting rules for the same guild prevent the save. \
Unticked backends stay stored but are inactive."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
(checkbox(
"voice_ns_stereo_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
"Suppression Strength",
&voice_noise_suppression.suppression_strength.to_string(),
Some(0), Some(100), "1",
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
experiment_delivery: &ExperimentDeliveryConfigResponse,
) -> Markup {
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 \
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! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"experiment_delivery_poll_interval_seconds",
"Assignment Poll Interval (s)",
&experiment_delivery.poll_interval_seconds.to_string(),
Some(60), Some(86400), "1",
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
))
(number_field(
"experiment_delivery_poll_jitter_percent",
"Assignment Poll Jitter (%)",
&experiment_delivery.poll_jitter_percent.to_string(),
Some(0), Some(50), "1",
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
))
}
(form_actions(html! {
(submit_button("Save Experiment Delivery Configuration"))
}))
}
}
},
)
}
fn registration_config_section(
config: &AdminConfig,
csrf_token: &str,
@@ -1522,3 +1785,49 @@ 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 {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
}],
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(markup.contains("1 of 200 stored"));
assert!(!markup.contains("at the cap"));
}
#[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)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
}
+12 -130
View File
@@ -10,7 +10,8 @@ use crate::{
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
submit_button,
},
message_list::{Attachment, Message, message_deletion_script, message_list},
message_data::{Message, ordered_messages, value_id},
message_list::{message_deletion_script, message_list},
page_container::{card, page_header},
},
layout::LayoutOptions,
@@ -19,7 +20,6 @@ use crate::{
};
use maud::{Markup, html};
use serde_json::Value;
use std::cmp::Ordering;
const MESSAGE_BROWSE_SCRIPT: &str = r#"
(function () {
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
show_delete: bool,
highlight_message_id: Option<&str>,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
@@ -295,7 +295,7 @@ fn browse_result_card(
csrf_token: &str,
context_limit: u32,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
@@ -345,7 +345,7 @@ fn search_result_card(
query_text: &str,
show_delete: bool,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let total = result
.get("total")
.and_then(Value::as_u64)
@@ -382,7 +382,7 @@ fn lookup_result_card(
show_delete: bool,
context_limit: u32,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let channel_id = messages
.first()
.map(|m| m.channel_id.as_str())
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
}
}
fn ordered_messages(result: &Value) -> Vec<Message> {
let mut messages: Vec<Message> = result
.get("messages")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(message_from_value)
.collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
fn response_messages(result: &Value) -> Vec<Message> {
let values = result["messages"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
ordered_messages(values)
}
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::cmp::Ordering;
use crate::{
acl,
api::types::ReportEntry,
@@ -13,7 +11,8 @@ use crate::{
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
form::csrf_input,
media::{guild_icon_url, initials, user_avatar_url},
message_list::{Attachment, Message, message_deletion_script, message_list},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
nsfw_indicators::{
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
},
@@ -26,7 +25,6 @@ use crate::{
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
use serde_json::Value;
fn status_badge(status: i32) -> Markup {
let (label, variant) = match status {
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
}))
}
}
fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
@@ -560,6 +560,8 @@ fn traits_form(
}
}
const DERIVED_TRAITS: [&str; 1] = ["premium"];
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
limit_config
.map(|response| {
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| !DERIVED_TRAITS.contains(value))
.collect()
})
.unwrap_or_default()
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.iter()
.map(String::as_str)
.filter(|trait_name| !trait_definitions.contains(trait_name))
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
@@ -18,19 +18,33 @@ use crate::{
},
};
use maud::{Markup, html};
use std::collections::HashMap;
use super::voice_servers_forms::{create_server_form, edit_server_form};
pub struct VoiceServersPageParams<'a> {
pub region_id: Option<&'a str>,
pub region_name: Option<&'a str>,
pub servers: Option<&'a [VoiceServer]>,
pub connection_counts: &'a HashMap<String, i64>,
pub error: Option<&'a str>,
pub csrf_token: &'a str,
}
pub fn voice_servers_page(
config: &AdminConfig,
auth: &AuthContext,
region_id: Option<&str>,
region_name: Option<&str>,
servers: Option<&[VoiceServer]>,
error: Option<&str>,
csrf_token: &str,
p: &VoiceServersPageParams<'_>,
) -> Markup {
let base = &config.base_path;
let VoiceServersPageParams {
region_id,
region_name,
servers,
connection_counts,
error,
csrf_token,
} = *p;
let options = LayoutOptions {
csrf_token,
inspected_voice_region_id: region_id,
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
html! {},
))
@if let Some(servers) = servers {
(servers_list(config, rid, servers, csrf_token))
(servers_list(config, rid, servers, connection_counts, csrf_token))
}
div id="create" class="mt-8" {
(create_server_form(config, rid, csrf_token))
@@ -109,6 +123,7 @@ fn servers_list(
config: &AdminConfig,
region_id: &str,
servers: &[VoiceServer],
connection_counts: &HashMap<String, i64>,
csrf_token: &str,
) -> Markup {
if servers.is_empty() {
@@ -121,7 +136,7 @@ fn servers_list(
html! {
div class="space-y-4" {
@for server in servers {
(server_card(config, region_id, server, csrf_token))
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
}
}
}
@@ -131,6 +146,7 @@ fn server_card(
config: &AdminConfig,
region_id: &str,
server: &VoiceServer,
connection_count: Option<i64>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
@@ -145,6 +161,15 @@ fn server_card(
let lng_str = server
.longitude
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
let soft_limit_str = server
.soft_connection_limit
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
let connections_str =
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
let at_soft_limit = matches!(
(server.soft_connection_limit, connection_count),
(Some(limit), Some(count)) if limit > 0 && count >= limit
);
card(html! {
div class="mb-4 flex flex-col gap-1" {
@@ -155,6 +180,9 @@ fn server_card(
} @else {
(badge("INACTIVE", BadgeVariant::Default))
}
@if at_soft_limit {
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
}
(voice_status_badges(vip_only, has_features, has_guild_ids))
}
p class="text-sm text-neutral-500" { (endpoint) }
@@ -164,6 +192,8 @@ fn server_card(
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
(data_field_text("Latitude", &lat_str))
(data_field_text("Longitude", &lng_str))
(data_field_text("Soft connection limit", &soft_limit_str))
(data_field_text("Live connections", &connections_str))
}
(voice_features_list(&server.required_guild_features))
(voice_guild_ids_list(&server.allowed_guild_ids))
@@ -26,6 +26,9 @@ pub fn edit_server_form(
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
let is_active = server.is_active.unwrap_or(false);
let soft_limit_val = server
.soft_connection_limit
.map_or_else(String::new, |v| v.to_string());
let vip_only = server.vip_only.unwrap_or(false);
let features_csv = server.required_guild_features.join(", ");
let guild_ids_csv = server.allowed_guild_ids.join(", ");
@@ -57,6 +60,15 @@ pub fn edit_server_form(
"Optional per-server coordinate override",
))
}
(form_field_with_helper(
"Soft Connection Limit",
&format!("{id_prefix}-soft-connection-limit"),
"soft_connection_limit",
"number",
&soft_limit_val,
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
(form_field_with_helper(
"API Key",
&format!("{id_prefix}-api-key"),
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
(form_field_with_helper(
"Soft Connection Limit (optional)",
"new-server-soft-connection-limit",
"soft_connection_limit",
"number",
"",
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
}
div class="space-y-3" {
(checkbox("is_active", "true", "Server is active", true, true))
+17 -3
View File
@@ -18,6 +18,7 @@ use tower::ServiceExt;
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
const ADMIN_ORIGIN: &str = "https://admin.example.test";
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
const CREATED_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForTests";
struct TestApp {
router: Router,
@@ -128,6 +129,10 @@ async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(status, StatusCode::OK, "{text}");
assert!(
text.contains("AdminUser"),
"the page did not render the admin the mock API returns"
);
let cookie_token = host_csrf_cookie(&headers)
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
@@ -166,7 +171,16 @@ async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusC
)
.await
.unwrap();
response.status()
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
if status == StatusCode::OK {
assert!(
text.contains(CREATED_KEY_SECRET),
"the action did not render the key the mock API creates"
);
}
status
}
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
@@ -207,11 +221,11 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
(Method::POST, "/admin/api-keys") => Json(json!({
"key_id": "1900000000000000001",
"key": "fa_1900000000000000001_OneTimeSecretForTests",
"key": CREATED_KEY_SECRET,
"name": "Legacy Cookie Key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": null,
+55 -13
View File
@@ -7,6 +7,7 @@ use axum::{
response::{IntoResponse, Response},
};
use fluxer_admin::{
api::{generated::types as generated_types, types::LookupGuildResponse},
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
@@ -430,6 +431,8 @@ 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_experiment_delivery",
][..],
),
];
@@ -903,8 +906,8 @@ fn user(id: &str, username: &str) -> Value {
})
}
fn searched_guild() -> Value {
json!({
fn searched_guild() -> generated_types::GuildAdminResponse {
serde_json::from_value(json!({
"id": "1600000000000000001",
"name": "Searched Guild",
"icon": null,
@@ -917,15 +920,14 @@ fn searched_guild() -> Value {
"features": ["COMMUNITY"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_text": null,
"description": "Guild used by HTMX acceptance tests.",
"vanity_url_code": null
})
"content_warning_level": 0,
"content_warning_text": null
}))
.expect("guild search fixture must match the generated response contract")
}
fn searched_guild_detail() -> Value {
json!({
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
serde_json::from_value(json!({
"id": "1600000000000000001",
"owner_id": "1500000000000000001",
"owner_username": "SearchedUser",
@@ -942,7 +944,7 @@ fn searched_guild_detail() -> Value {
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 0,
"default_message_notifications": 0,
@@ -954,9 +956,23 @@ fn searched_guild_detail() -> Value {
"disabled_operations": 0,
"member_count": 12,
"channels": [],
"roles": [],
"description": "Guild used by HTMX acceptance tests."
})
"roles": []
}))
.expect("guild detail fixture must match the generated response contract")
}
#[test]
fn guild_fixtures_match_generated_response_contracts() {
let search = searched_guild();
assert_eq!(search.name, "Searched Guild");
assert_eq!(*search.member_count, 12);
let response: LookupGuildResponse =
serde_json::from_value(json!({"guild": searched_guild_detail()})).unwrap();
let detail = response.guild.unwrap();
assert_eq!(detail.name, "Searched Guild");
assert_eq!(detail.id, "1600000000000000001");
assert_eq!(detail.member_count, 12);
}
fn searched_application() -> Value {
@@ -1081,6 +1097,32 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,
"default_backend": "standard",
"enabled_backends": [
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
],
"allow_user_override": true,
"rollout_basis_points": 0,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
},
"registration": registration_config(),
"self_hosted": false
})
+26
View File
@@ -3,6 +3,7 @@
#[path = "parity/mod.rs"]
mod parity_support;
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
use parity_support::{
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
html_normalizer, rust_server,
@@ -48,6 +49,31 @@ fn html_normalizer_allows_intentional_rust_markup_fixes() {
);
}
#[test]
fn guild_search_fixture_matches_the_generated_response_contract() {
let response: SearchGuildsResponse =
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
.expect("guild search fixture must match the generated response contract");
assert_eq!(response.guilds.len(), 1);
let guild = &response.guilds[0];
assert_eq!(guild.name, "Parity Guild");
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
}
#[test]
fn guild_lookup_fixture_matches_the_generated_response_contract() {
let response: LookupGuildResponse =
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
.expect("guild lookup fixture must match the generated response contract");
let guild = response
.guild
.expect("guild lookup fixture must contain a guild");
assert_eq!(String::from(guild.name), "Parity Guild");
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
assert_eq!(guild.channels.len(), 1);
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
let api_server = api_fixtures::ApiFixtureServer::start_default()
@@ -16,7 +16,7 @@
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 2,
"default_message_notifications": 1,
@@ -36,7 +36,7 @@
"parent_id": null,
"nsfw": false,
"nsfw_override": null,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"url": null
}
@@ -51,7 +51,6 @@
"hoist": false,
"mentionable": false
}
],
"description": "Guild used by admin parity fixtures."
]
}
}
@@ -13,10 +13,8 @@
"features": ["COMMUNITY", "DISCOVERABLE"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_text": null,
"description": "Guild used by admin parity fixtures.",
"vanity_url_code": "parity"
"content_warning_level": 0,
"content_warning_text": null
}
],
"total": 1
@@ -0,0 +1,336 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
const REGION_ID: &str = "europe-north";
const SERVER_ID: &str = "europe-north-server-1";
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
#[tokio::test]
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
assert_eq!(body["soft_connection_limit"], Value::Null);
assert_eq!(body["vip_only"], json!(false));
}
#[tokio::test]
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
let object = body.as_object().expect("object body");
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], json!(false));
assert_eq!(body["vip_only"], json!(true));
}
#[tokio::test]
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-regions?action=update",
&format!(
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
}
struct TestApp {
router: Router,
session_cookie: String,
captured: CapturedBodies,
}
async fn setup() -> TestApp {
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
let router = build_router(test_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
captured,
}
}
fn captured_body(app: &TestApp, route: &str) -> Value {
let captured = app.captured.lock().expect("captured bodies");
captured
.iter()
.find(|(seen, _)| seen == route)
.map(|(_, body)| body.clone())
.unwrap_or_else(|| {
panic!(
"no request captured for {route}, saw {:?}",
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
)
})
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/voice-regions")
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(
header::COOKIE,
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
)
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn spawn_mock_api(captured: CapturedBodies) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
Router::new().fallback(mock_api).with_state(captured),
)
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(
State(captured): State<CapturedBodies>,
method: Method,
uri: Uri,
request: Request<Body>,
) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH {
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
captured
.lock()
.expect("captured bodies")
.push((format!("PATCH {path}"), body));
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::PATCH, "/admin/voice/regions/europe-north") => {
Json(json!({ "region": region() })).into_response()
}
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
Json(json!({ "server": server() })).into_response()
}
(Method::GET, "/admin/voice/regions") => {
Json(json!({ "regions": [region()] })).into_response()
}
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn region() -> Value {
json!({
"id": REGION_ID,
"name": "Northern Europe",
"emoji": "flag",
"latitude": 59.33,
"longitude": 18.06,
"is_default": true,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn server() -> Value {
json!({
"region_id": REGION_ID,
"server_id": SERVER_ID,
"endpoint": "wss://voice.example.com",
"latitude": null,
"longitude": null,
"is_active": true,
"soft_connection_limit": null,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
-1
View File
@@ -22,7 +22,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
-1
View File
@@ -92,7 +92,6 @@
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
},
"packageManager": "[email protected]"
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -21,7 +21,6 @@
"@types/node": "catalog:",
"@types/nodemailer": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -2
View File
@@ -1,11 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
export default defineConfig({
root: process.cwd(),
plugins: [tsconfigPaths()],
resolve: {tsconfigPaths: true},
cacheDir: './node_modules/.vitest',
test: {
globals: true,
-1
View File
@@ -18,7 +18,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
@@ -215,7 +215,7 @@ function createBlockedRequestError(url: URL, context: RequestUrlValidationContex
}
async function defaultLookupHost(hostname: string): Promise<Array<string>> {
const addresses = await dns.promises.lookup(hostname, {all: true, verbatim: true});
const addresses = await dns.promises.lookup(hostname, {all: true, order: 'verbatim'});
return addresses.map((addressEntry) => addressEntry.address);
}
@@ -234,7 +234,7 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
if (error) {
callback(error, []);
return;
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
@@ -38,7 +38,6 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
"vitest": "catalog:"
}
}
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
+55
View File
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
};
}
function withStripeLegacyPrices(
master: MasterConfig,
legacyPrices: Record<string, Array<string> | undefined> | undefined,
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
legacy_prices: legacyPrices,
},
},
};
}
describe('buildAPIConfigFromMaster upload relay secret', () => {
let master: MasterConfig;
beforeAll(async () => {
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
);
});
});
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('carries the retired stripe price map from master config onto the api config', () => {
const legacyPrices = {
monthly_brl: ['price_retired_monthly_brl'],
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
};
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
legacyPrices,
);
});
it('carries the retired price map even when no live prices are configured', () => {
const withoutPrices: MasterConfig = {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
prices: undefined,
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
},
},
};
const config = buildAPIConfigFromMaster(withoutPrices);
expect(config.stripe.prices).toBeUndefined();
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
});
it('leaves the retired price map undefined when master config does not set one', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
+13 -9
View File
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
monthlyEur: master.integrations.stripe.prices.monthly_eur,
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
monthlyTry: master.integrations.stripe.prices.monthly_try,
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
yearlyEur: master.integrations.stripe.prices.yearly_eur,
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
yearlyTry: master.integrations.stripe.prices.yearly_try,
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
@@ -395,6 +407,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
}
: undefined,
legacyPrices: master.integrations.stripe.legacy_prices,
},
bunny: {
purgeEnabled: master.integrations.bunny.purge_enabled,
@@ -518,15 +531,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
laneName: apiWorkerConfig?.lane,
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+26 -75
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
InputValidationError,
type LocalizedValidationError,
} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import type {ZodError, ZodTypeAny} from 'zod';
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
import {requireRequestJsonBody} from './utils/RequestJsonBody';
import {initializeFluxerErrorMap} from './ZodErrorMap';
@@ -19,12 +20,6 @@ function isEmptyObject(obj: object): boolean {
return Object.keys(obj).length === 0;
}
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getValidationErrorCode(message: string): ValidationErrorCode {
if (isValidationErrorCode(message)) {
return message;
@@ -32,72 +27,37 @@ function getValidationErrorCode(message: string): ValidationErrorCode {
return ValidationErrorCodes.INVALID_FORMAT;
}
interface ZodTooSmallIssue {
code: 'too_small';
minimum: number | bigint;
type: string;
}
interface ZodTooBigIssue {
code: 'too_big';
maximum: number | bigint;
type: string;
}
function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue {
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
}
function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue {
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
}
interface ZodInvalidTypeIssue {
code: 'invalid_type';
expected: string;
received: string;
}
interface ZodCustomIssue {
code: 'custom';
params?: Record<string, unknown>;
}
function isInvalidTypeIssue(
issue: ZodError['issues'][number],
): issue is ZodError['issues'][number] & ZodInvalidTypeIssue {
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
}
function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue {
return issue.code === 'custom';
}
function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record<string, unknown> | undefined {
function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknown> {
const path = issue.path;
const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field';
if (isTooSmallIssue(issue)) {
if (issue.code === 'too_small') {
return {name: fieldName, min: issue.minimum, minValue: issue.minimum};
}
if (isTooBigIssue(issue)) {
if (issue.code === 'too_big') {
return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum};
}
if (isInvalidTypeIssue(issue)) {
return {name: fieldName, expected: issue.expected, received: issue.received};
if (issue.code === 'invalid_type') {
return {name: fieldName, expected: issue.expected};
}
if (isCustomIssue(issue) && issue.params) {
if (issue.code === 'custom' && issue.params) {
return {name: fieldName, ...issue.params};
}
return {name: fieldName};
}
function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
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;
if (typeof obj === 'string' && obj === '') return null;
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
if (obj !== null && typeof obj === 'object') {
if (isEmptyObject(obj) && !isRoot) return null;
const shape = schema instanceof ZodObject ? schema.shape : undefined;
const processed = Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]),
Object.entries(obj).map(([key, value]) => [
key,
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
]),
);
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
return processed;
@@ -106,24 +66,15 @@ function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
}
type HasUndefined<T> = undefined extends T ? true : false;
type SafeParseResult<T extends ZodTypeAny> =
| {
success: true;
data: T['_output'];
}
| {
success: false;
error: ZodError<T['_input']>;
};
type Hook<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
V extends Input = Input,
O = Record<string, unknown>,
> = (
result: SafeParseResult<T> & {
result: ZodSafeParseResult<output<T>> & {
target: Target;
},
c: Context<E, P, V>,
@@ -134,7 +85,7 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
target: Target,
) => unknown | Promise<unknown>;
type ValidatorOptions<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
@@ -144,7 +95,7 @@ type ValidatorOptions<
post?: Hook<T, E, P, Target, V>;
};
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
export function inputValidationErrorFromZodIssues(issues: Array<core.$ZodIssue>): InputValidationError {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
const seen = new Set<string>();
@@ -162,12 +113,12 @@ export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): I
}
export const Validator = <
T extends ZodTypeAny,
T extends ZodType,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = T['_input'],
Out = T['_output'],
In = input<T>,
Out = output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
@@ -248,7 +199,7 @@ export const Validator = <
if (options.pre) {
value = await options.pre(value, c, target);
}
const transformedValue = convertEmptyValuesToNull(value);
const transformedValue = convertEmptyValuesToNull(value, schema);
const result = await schema.safeParseAsync(transformedValue);
if (options.post) {
const hookResult = await options.post({...result, target}, c);
+7 -37
View File
@@ -1,37 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {z} from 'zod';
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
type FluxerZodErrorMapIssue = z.core.$ZodRawIssue;
type FluxerZodErrorMapResult =
| {
message: string;
}
| string
| undefined
| null;
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getParamsProperty(obj: object): Record<string, unknown> | undefined {
if ('params' in obj) {
const value = (
obj as {
params?: unknown;
}
).params;
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}
return undefined;
}
function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResult {
const fluxerZodErrorMap: z.core.$ZodErrorMap = (issue) => {
if (issue.message && isValidationErrorCode(issue.message)) {
return {message: issue.message};
}
@@ -58,8 +31,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'too_big': {
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'string') {
if (issue.origin === 'string') {
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -67,10 +39,9 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'invalid_format': {
const format = 'format' in issue ? String(issue.format) : undefined;
if (format === 'email') {
if (issue.format === 'email') {
errorCode = ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
} else if (format === 'uuid') {
} else if (issue.format === 'uuid') {
errorCode = ValidationErrorCodes.INVALID_SNOWFLAKE;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -82,8 +53,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'custom': {
const params = getParamsProperty(issue);
const customErrorCode = params?.['error_code'];
const customErrorCode = issue.params?.['error_code'];
errorCode =
typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)
? customErrorCode
@@ -101,7 +71,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
}
}
return {message: errorCode};
}
};
export function initializeFluxerErrorMap(): void {
z.config({customError: fluxerZodErrorMap});
@@ -2,6 +2,7 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
AdminApiKeyListResponse,
CreateAdminApiKeyRequest,
CreateAdminApiKeyResponse,
type CreateAdminApiKeyResponse as CreateAdminApiKeyResponseType,
@@ -11,7 +12,7 @@ import {
UpdateAdminApiKeyRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {z} from 'zod';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -72,7 +73,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
OpenAPI({
operationId: 'list_admin_api_keys',
summary: 'List admin API keys',
responseSchema: z.array(ListAdminApiKeyResponse),
responseSchema: AdminApiKeyListResponse,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
@@ -3,8 +3,11 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {
AdminArchiveCreateRequest,
AdminArchiveResponseSchema,
type ArchiveSubjectType,
} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {
AdminArchiveCreateRequest,
DownloadUrlResponseSchema,
GetArchiveResponseSchema,
ListArchivesQuery,
@@ -19,7 +22,7 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean {
function canViewArchive(adminAcls: Set<string>, subjectType: ArchiveSubjectType): boolean {
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
@@ -39,7 +39,7 @@ import {
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodTypeAny, z} from 'zod';
import type {ZodType} from 'zod';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -221,7 +221,7 @@ function requireProfileSubstringScope(scope: ProfileSubstringScope | undefined):
return scope;
}
async function parseBlocklistBody<T extends ZodTypeAny>(schema: T, value: unknown): Promise<z.infer<T>> {
async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promise<T> {
const result = await schema.safeParseAsync(value);
if (!result.success) {
throw inputValidationErrorFromZodIssues(result.error.issues);
@@ -6,17 +6,17 @@ import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas
import {
DiscoveryAdminApplicationUpdateRequest,
DiscoveryAdminCategoryListingQuery,
DiscoveryAdminListedGuildResponse,
DiscoveryAdminListedGuildListResponse,
DiscoveryAdminListingBulkCategoryRequest,
DiscoveryAdminListingBulkCategoryResponse,
DiscoveryAdminPendingApplicationResponse,
DiscoveryAdminPendingApplicationListResponse,
DiscoveryAdminRemoveRequest,
DiscoveryApplicationPatchRequest,
DiscoveryApplicationResponse,
DiscoveryCategoryIdParam,
DiscoveryCategoryListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {z} from 'zod';
import {createGuildID} from '../../BrandedTypes';
import type {GuildDiscoveryRow} from '../../database/types/GuildDiscoveryTypes';
import {mapGuildFeatures} from '../../guild/GuildFeatureUtils';
@@ -138,7 +138,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List discovery applications',
description:
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
responseSchema: DiscoveryAdminPendingApplicationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -214,7 +214,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List guilds in a discovery category',
description:
'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -249,7 +249,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List discovery listings',
description:
'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -17,9 +17,8 @@ import {
ListGuildStickersResponse,
LookupGuildResponse,
SearchGuildsResponse,
SuccessResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildIdParam, GuildIdUserIdParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import {createGuildID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
@@ -14,14 +14,16 @@ import {
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {
type InstanceBrandingConfig,
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
@@ -51,9 +53,19 @@ function omitUndefinedFields<T extends object>(value: T): Partial<T> {
async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
const instanceConfigRepository = getInstanceConfigRepository();
const [ssoConfig, gatewayRollout, registrationConfig, registrationUrls, pendingRegistrations] = await Promise.all([
const [
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
experimentDelivery,
registrationConfig,
registrationUrls,
pendingRegistrations,
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
@@ -83,6 +95,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
urls: registrationUrls,
@@ -214,6 +228,26 @@ export function InstanceConfigAdminController(app: HonoApp) {
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
...currentExperimentDelivery,
...data.experiment_delivery,
});
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
}
if (data.sso) {
const sso = data.sso;
const current = await instanceConfigRepository.getSsoConfig({includeSecret: true});
@@ -404,7 +438,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
base64Image: image ?? null,
errorPath: 'image',
});
const brandingPatch: Partial<InstanceBrandingConfig> = {[`${kind}_url`]: prepared.newCdnUrl};
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
return ctx.json(await buildInstanceConfigResponse());
},
@@ -23,6 +23,7 @@ export function SystemAdminController(app: HonoApp) {
description:
'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
responseSchema: HeapSnapshotResponse,
responseContentType: 'application/octet-stream',
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -5,6 +5,7 @@ import {
CreateVoiceRegionRequest,
CreateVoiceRegionResponse,
CreateVoiceServerRequest,
CreateVoiceServerRequestBody,
CreateVoiceServerResponse,
DeleteVoiceResponse,
GetVoiceRegionQuery,
@@ -14,8 +15,10 @@ import {
ListVoiceRegionsResponse,
ListVoiceServersResponse,
UpdateVoiceRegionRequest,
UpdateVoiceRegionRequestBody,
UpdateVoiceRegionResponse,
UpdateVoiceServerRequest,
UpdateVoiceServerRequestBody,
UpdateVoiceServerResponse,
VoiceRegionIdParam,
VoiceServerIdParam,
@@ -117,6 +120,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_region',
summary: 'Update voice region',
requestSchema: UpdateVoiceRegionRequestBody,
responseSchema: UpdateVoiceRegionResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -195,6 +199,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'create_admin_voice_server',
summary: 'Create voice server',
requestSchema: CreateVoiceServerRequestBody,
responseSchema: CreateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -247,6 +252,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_server',
summary: 'Update voice server',
requestSchema: UpdateVoiceServerRequestBody,
responseSchema: UpdateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -1,11 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
export type ArchiveSubjectType = 'user' | 'guild';
export class AdminArchive {
subjectType: ArchiveSubjectType;
subjectId: bigint;
@@ -61,22 +58,7 @@ export class AdminArchive {
};
}
toResponse(): {
archive_id: string;
subject_type: ArchiveSubjectType;
subject_id: string;
requested_by: string;
requested_at: string;
started_at: string | null;
completed_at: string | null;
failed_at: string | null;
file_size: string | null;
progress_percent: number;
progress_step: string | null;
error_message: string | null;
download_url_expires_at: string | null;
expires_at: string | null;
} {
toResponse(): AdminArchiveResponse {
return {
archive_id: this.archiveId.toString(),
subject_type: this.subjectType,
@@ -95,5 +77,3 @@ export class AdminArchive {
};
}
}
export type AdminArchiveResponse = z.infer<typeof AdminArchiveResponseSchema>;
@@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {ms} from 'itty-time';
import {BatchBuilder, fetchMany, fetchOne} from '../../database/CassandraQueryExecution';
import {Db} from '../../database/CassandraTypes';
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
import {Logger} from '../../Logger';
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '../../Tables';
import type {ArchiveSubjectType} from '../models/AdminArchiveModel';
import {AdminArchive} from '../models/AdminArchiveModel';
const RETENTION_DAYS = 365;
@@ -7,6 +7,7 @@ import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestF
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms, seconds} from 'itty-time';
import type {GuildID, UserID} from '../../BrandedTypes';
@@ -16,7 +17,7 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {IStorageService} from '../../infrastructure/IStorageService';
import type {IUserRepository} from '../../user/IUserRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import {AdminArchive, type AdminArchiveResponse, type ArchiveSubjectType} from '../models/AdminArchiveModel';
import {AdminArchive} from '../models/AdminArchiveModel';
import type {AdminArchiveRepository} from '../repositories/AdminArchiveRepository';
const ARCHIVE_RETENTION_DAYS = 365;
@@ -130,19 +131,14 @@ export class AdminArchiveService {
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
if (subjectId !== undefined && subjectType === 'all') {
throw InputValidationError.create(
'subject_type',
'subject_type must name user or guild when subject_id is supplied',
);
}
if (subjectId !== undefined) {
const archives = await this.adminArchiveRepository.listBySubject(
subjectType as ArchiveSubjectType,
subjectId,
limit,
includeExpired,
);
if (subjectType === 'all') {
throw InputValidationError.create(
'subject_type',
'subject_type must name user or guild when subject_id is supplied',
);
}
const archives = await this.adminArchiveRepository.listBySubject(subjectType, subjectId, limit, includeExpired);
return archives.map((a) => a.toResponse());
}
if (requestedBy !== undefined) {
@@ -159,11 +155,7 @@ export class AdminArchiveService {
.slice(0, limit)
.map((a) => a.toResponse());
}
const archives = await this.adminArchiveRepository.listByType(
subjectType as ArchiveSubjectType,
limit,
includeExpired,
);
const archives = await this.adminArchiveRepository.listByType(subjectType, limit, includeExpired);
return archives.map((a) => a.toResponse());
}
@@ -1,5 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
AdminAuditLogChannelSummary,
AdminAuditLogGuildSummary,
AdminAuditLogResponse,
AdminAuditLogUserSummary,
AuditLogsListResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserID} from '../../BrandedTypes';
import {createChannelID, createGuildID, createUserID} from '../../BrandedTypes';
import type {IChannelRepository} from '../../channel/IChannelRepository';
@@ -70,10 +77,7 @@ export class AdminAuditService {
target_id?: string;
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const auditLogSearchService = getAuditLogSearchService();
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
@@ -116,10 +120,7 @@ export class AdminAuditService {
sort_order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const auditLogSearchService = getAuditLogSearchService();
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
@@ -164,10 +165,7 @@ export class AdminAuditService {
targetId?: bigint;
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const limit = data.limit || 50;
const allLogs = await this.adminRepository.listAllAuditLogsPaginated(limit + (data.offset || 0));
let filteredLogs = allLogs;
@@ -320,24 +318,6 @@ export class AdminAuditService {
}
}
interface AdminAuditLogResponse {
log_id: string;
admin_user_id: string;
admin_user: AuditLogUserSummary | null;
target_type: string;
target_id: string;
target_user: AuditLogUserSummary | null;
target_guild: AuditLogGuildSummary | null;
target_channel: AuditLogChannelSummary | null;
related_users: Record<string, AuditLogUserSummary>;
related_guilds: Record<string, AuditLogGuildSummary>;
related_channels: Record<string, AuditLogChannelSummary>;
action: string;
audit_log_reason: string | null;
metadata: Record<string, string>;
created_at: string;
}
interface AuditLogEnrichmentDeps {
userRepository?: Pick<IUserRepository, 'findUnique'>;
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
@@ -350,25 +330,6 @@ interface AuditLogEnrichment {
channels: Map<string, Channel>;
}
interface AuditLogUserSummary {
id: string;
username: string;
discriminator: string;
global_name: string | null;
}
interface AuditLogGuildSummary {
id: string;
name: string;
}
interface AuditLogChannelSummary {
id: string;
name: string | null;
type: number;
guild_id: string | null;
}
const USER_TARGET_TYPES = new Set(['user', 'guild_member', 'message_deletion', 'message_shred']);
const SNOWFLAKE_RE = /^(0|[1-9][0-9]*)$/;
@@ -388,7 +349,7 @@ function isChannelIdKey(key: string): boolean {
return key === 'channel_id' || key.endsWith('_channel_id');
}
function mapUserSummary(user: User | null): AuditLogUserSummary | null {
function mapUserSummary(user: User | null): AdminAuditLogUserSummary | null {
if (!user) return null;
return {
id: user.id.toString(),
@@ -398,7 +359,7 @@ function mapUserSummary(user: User | null): AuditLogUserSummary | null {
};
}
function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
function mapGuildSummary(guild: Guild | null): AdminAuditLogGuildSummary | null {
if (!guild) return null;
return {
id: guild.id.toString(),
@@ -406,7 +367,7 @@ function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
};
}
function mapChannelSummary(channel: Channel | null): AuditLogChannelSummary | null {
function mapChannelSummary(channel: Channel | null): AdminAuditLogChannelSummary | null {
if (!channel) return null;
return {
id: channel.id.toString(),
@@ -229,14 +229,11 @@ export class AdminMessageService {
hitsPerPage: limit,
page: 1,
});
const messageEntries = result.hits.map((hit) => ({
channelId: createChannelID(BigInt(hit.channelId)),
messageId: createMessageID(BigInt(hit.id)),
}));
const resolvedMessages = await Promise.all(
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
);
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
const messageResponses = await createMessageResponseDataService().buildMessages({
userId: createUserID(0n),
messages: result.messages,
access: await this.getMessageResponseAccessForAdmin(channelId),
});
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
const priorReports = await this.getPriorReportsForMessages(messageResponses);
const adminMessages = messageResponses.map((message) =>
@@ -282,19 +279,6 @@ export class AdminMessageService {
});
}
private async getMessageResponseForAdmin(
channelId: ChannelID,
messageId: MessageID,
): Promise<MessageResponse | null> {
const access = await this.getMessageResponseAccessForAdmin(channelId);
return createMessageResponseDataService().getMessage({
userId: createUserID(0n),
channelId,
messageId,
access,
});
}
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
@@ -5,6 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import type {ApiContext} from '../../ApiContext';
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
import {isSyntheticUserId} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {Logger} from '../../Logger';
import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';
@@ -129,12 +130,11 @@ export class AdminSearchService {
throw new FeatureTemporarilyDisabledError();
}
const query = data.query?.trim() || '';
const isIdQuery = /^\d+$/.test(query);
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
const [searchResult, directUser] = await Promise.all([
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
isIdQuery && data.offset === 0
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
: Promise.resolve(null),
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
]);
const {hits, total} = searchResult;
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
@@ -3,6 +3,7 @@
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ApiContext} from '../../ApiContext';
import {createUserID} from '../../BrandedTypes';
import {isSyntheticUserId} from '../../constants/Core';
import {Logger} from '../../Logger';
import {mapUserToAdminResponse} from '../models/UserTypes';
@@ -32,7 +33,7 @@ export class AdminUserLookupService {
} else if (/^\d+$/.test(query)) {
try {
const userId = createUserID(BigInt(query));
user = await userRepository.findUnique(userId);
user = isSyntheticUserId(userId) ? null : await userRepository.findUnique(userId);
} catch (error) {
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
user = null;
@@ -45,6 +45,7 @@ import {Logger} from '../../Logger';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
import {resolveAssignedTraits} from '../../user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {mapUserToAdminResponse} from '../models/UserTypes';
@@ -383,7 +384,8 @@ export class AdminUserSecurityService {
if (!user) {
throw new UnknownUserError();
}
const traitSet = data.traits.length > 0 ? new Set(data.traits) : null;
const assigned = resolveAssignedTraits(user.traits ?? [], data.traits);
const traitSet = assigned.size > 0 ? assigned : null;
const updatedUser = await userRepository.patchUpsert(
userId,
{
@@ -235,6 +235,7 @@ export class AdminVoiceService {
serverId: data.server_id,
endpoint: data.endpoint,
isActive: data.is_active ?? true,
softConnectionLimit: data.soft_connection_limit ?? null,
apiKey: data.api_key ?? null,
apiSecret: data.api_secret ?? null,
latitude: data.latitude ?? null,
@@ -271,6 +272,7 @@ export class AdminVoiceService {
if (data.latitude !== undefined) updates.latitude = data.latitude;
if (data.longitude !== undefined) updates.longitude = data.longitude;
if (data.is_active !== undefined) updates.isActive = data.is_active;
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
updates.updatedAt = new Date();
await voiceRepository.upsertServer(updates);
@@ -339,6 +341,7 @@ export class AdminVoiceService {
latitude: server.latitude ?? null,
longitude: server.longitude ?? null,
is_active: server.isActive,
soft_connection_limit: server.softConnectionLimit ?? null,
vip_only: server.restrictions.vipOnly,
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
allowed_guild_ids: allowedGuildIds,
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createGuildID, type GuildID, type UserID} from '../../../BrandedTypes';
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '../../../guild/services/GuildService';
@@ -17,7 +18,7 @@ interface AdminGuildManagementServiceDeps {
export class AdminGuildManagementService {
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
const {guildRepository, gatewayService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
const guild = await guildRepository.findUnique(guildId);
@@ -36,7 +37,11 @@ export class AdminGuildManagementService {
return {success: true};
}
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async shutdownGuild(
guildIdRaw: bigint,
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SuccessResponse> {
const {guildRepository, gatewayService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
const guild = await guildRepository.findUnique(guildId);
@@ -55,7 +60,7 @@ export class AdminGuildManagementService {
return {success: true};
}
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
const {guildService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
await guildService.data.deleteGuildForAdmin(guildId, auditLogReason);

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