mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 05:02:24 +09:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd62eb661 | ||
|
|
7e177a1c90 | ||
|
|
78471d8a85 | ||
|
|
6b6ae13919 | ||
|
|
7197371123 | ||
|
|
70fd9e6331 | ||
|
|
c14b7d4cb3 | ||
|
|
0f0ff5f3df | ||
|
|
b4c614d21a | ||
|
|
bef5e33eec | ||
|
|
e5a568acca | ||
|
|
a208b56354 | ||
|
|
71be6c1de4 | ||
|
|
eae905b1da | ||
|
|
10cd4f96a4 | ||
|
|
9d74daf8ad | ||
|
|
6f344def93 | ||
|
|
72f9f9c396 | ||
|
|
c8755885d4 | ||
|
|
12d503d4c5 | ||
|
|
2f6201bcbd |
@@ -28,7 +28,6 @@ services:
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -166,11 +165,6 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/http_client/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-initialization-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/initialization/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-kv-client-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/kv_client/node_modules
|
||||
@@ -371,7 +365,6 @@ volumes:
|
||||
fluxer-api-email-node-modules:
|
||||
fluxer-api-geoip-node-modules:
|
||||
fluxer-api-http-client-node-modules:
|
||||
fluxer-api-initialization-node-modules:
|
||||
fluxer-api-kv-client-node-modules:
|
||||
fluxer-api-locale-node-modules:
|
||||
fluxer-api-media-proxy-utils-node-modules:
|
||||
|
||||
@@ -740,7 +740,6 @@ jobs:
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
@@ -15,7 +15,6 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
CARGO_PROFILE_DEV_DEBUG: none
|
||||
CARGO_PROFILE_TEST_DEBUG: none
|
||||
CARGO_INCREMENTAL: '0'
|
||||
@@ -43,7 +42,7 @@ jobs:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
@@ -56,7 +55,7 @@ jobs:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -98,7 +97,7 @@ jobs:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
@@ -127,7 +126,6 @@ jobs:
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
@@ -150,7 +148,6 @@ jobs:
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
@@ -309,7 +306,7 @@ jobs:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
@@ -390,7 +387,7 @@ jobs:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
@@ -419,7 +416,6 @@ jobs:
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
@@ -442,7 +438,6 @@ jobs:
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
|
||||
/fluxer_gateway/priv/
|
||||
|
||||
/fluxer_desktop/native/rust/fuzz/artifacts/
|
||||
/fluxer_desktop/native/rust/fuzz/corpus/
|
||||
/packages/markdown_parser/rust/fuzz/artifacts/
|
||||
/packages/markdown_parser/rust/fuzz/corpus/
|
||||
|
||||
|
||||
Generated
-27
@@ -1737,7 +1737,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
@@ -1781,7 +1780,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2017,24 +2015,17 @@ name = "fluxer_common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2637,12 +2628,6 @@ version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2891,18 +2876,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"memchr",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
|
||||
@@ -163,7 +163,6 @@
|
||||
"**",
|
||||
"!**/.git",
|
||||
"!**/app.css",
|
||||
"!fluxer_admin/public/static/app.css",
|
||||
"!**/build",
|
||||
"fluxer_app/scripts/build",
|
||||
"!**/dist",
|
||||
|
||||
Vendored
-2
@@ -120,8 +120,6 @@ FLUXER_TEST_MODE_ENABLED=false
|
||||
|
||||
PUBLIC_BUILD_VERSION=dev
|
||||
PUBLIC_RELEASE_CHANNEL=canary
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
|
||||
AWS_EC2_METADATA_DISABLED=true
|
||||
|
||||
@@ -56,7 +56,6 @@ workloads:
|
||||
name: fluxer-admin
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
@@ -73,8 +72,6 @@ workloads:
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
|
||||
@@ -297,6 +297,11 @@ LIVEKIT_API_SECRET=CHANGE_ME
|
||||
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
|
||||
#FLUXER_LIVEKIT_LOG_LEVEL=info
|
||||
|
||||
# STUN servers for peer-to-peer calls, which need the voice_p2p experiment turned
|
||||
# on in the admin panel. STUN finds each peer's public address. The value below
|
||||
# is an example.
|
||||
#FLUXER_VOICE_P2P_STUN_URLS=stun:stun.example.com:3478
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
#FLUXER_YOUTUBE_API_KEY=
|
||||
# Hosts the api never unfurls, comma separated.
|
||||
@@ -372,7 +377,6 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_INVITE_ENDPOINT=
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
@@ -626,8 +630,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
|
||||
|
||||
# app-proxy discovery refresh, index upstream and manifest scope.
|
||||
#DISCOVERY_REFRESH_INTERVAL_MS=60000
|
||||
# app-proxy index upstream and manifest scope.
|
||||
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
|
||||
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
|
||||
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
|
||||
|
||||
@@ -104,6 +104,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '${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}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
FLUXER_VOICE_P2P_STUN_URLS: ${FLUXER_VOICE_P2P_STUN_URLS:-}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
|
||||
@@ -655,25 +656,10 @@ services:
|
||||
environment:
|
||||
RUST_LOG: ${RUST_LOG:-}
|
||||
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:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
|
||||
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_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
|
||||
@@ -11268,6 +11268,7 @@
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"voice_p2p": {"$ref": "#/components/schemas/VoiceP2pConfigResponse"},
|
||||
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
@@ -11682,6 +11683,7 @@
|
||||
"gateway_rollout",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"voice_p2p",
|
||||
"captcha",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
@@ -11820,6 +11822,7 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_p2p": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/VoiceP2pConfigUpdateRequest"}]},
|
||||
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
@@ -14679,6 +14682,10 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_message_id": {
|
||||
"description": "The ID of the last message sent in this channel",
|
||||
"nullable": true,
|
||||
@@ -16314,11 +16321,38 @@
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
|
||||
}
|
||||
},
|
||||
"VoiceP2pConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"max_participants": {"type": "integer", "minimum": 2, "maximum": 4}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
@@ -16355,6 +16389,11 @@
|
||||
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
|
||||
}
|
||||
},
|
||||
"ExperimentRolloutCountryCodesSchema": {
|
||||
"maxItems": 250,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^[A-Z]{2}$"}
|
||||
},
|
||||
"BillingLegacyPricesSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -16470,12 +16509,68 @@
|
||||
"required": ["enabled", "cost", "max_counter"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceP2pConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {
|
||||
"default": [],
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
|
||||
},
|
||||
"rollout_salt": {
|
||||
"default": "voice-p2p-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"max_participants": {"default": 2, "type": "integer", "minimum": 2, "maximum": 4}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_country_codes",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"max_participants"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_country_codes": {
|
||||
"default": [],
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
|
||||
},
|
||||
"rollout_salt": {
|
||||
"default": "domain-migration-v1",
|
||||
"type": "string",
|
||||
@@ -16509,6 +16604,7 @@
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_country_codes",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
use super::types::{BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -249,16 +249,6 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_profile_substring(
|
||||
&self,
|
||||
scope: &str,
|
||||
|
||||
@@ -251,22 +251,6 @@ impl AdminApiClient {
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
|
||||
@@ -284,11 +284,6 @@ pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BanAvatarResult {
|
||||
pub hash_short: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub voice_p2p: VoiceP2pConfigResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
@@ -506,7 +508,10 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES: usize = 250;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const VOICE_P2P_DEFAULT_SALT: &str = "voice-p2p-v1";
|
||||
pub const VOICE_P2P_MAX_PARTICIPANTS_RANGE: std::ops::RangeInclusive<u32> = 2..=4;
|
||||
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
|
||||
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
|
||||
|
||||
@@ -530,6 +535,7 @@ pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
@@ -545,6 +551,7 @@ impl Default for DomainMigrationConfigResponse {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
@@ -563,6 +570,8 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_country_codes: Option<Vec<String>>,
|
||||
#[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>>,
|
||||
@@ -578,6 +587,60 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceP2pConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub max_participants: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceP2pConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: VOICE_P2P_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
max_participants: *VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceP2pConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_country_codes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_participants: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CaptchaConfigResponse {
|
||||
@@ -724,6 +787,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_p2p: Option<VoiceP2pConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
@@ -1028,17 +1093,22 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let voice_p2p = serde_json::from_value::<VoiceP2pConfigResponse>(json!({}))
|
||||
.expect("default voice p2p config");
|
||||
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
|
||||
.expect("default captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let voice_p2p = serde_json::to_value(voice_p2p).expect("serializable voice p2p config");
|
||||
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_voice_p2p: generated_types::VoiceP2pConfigResponse =
|
||||
serde_json::from_value(voice_p2p.clone()).expect("generated voice p2p config contract");
|
||||
let generated_captcha: generated_types::CaptchaConfigResponse =
|
||||
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
@@ -1048,6 +1118,11 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_voice_p2p)
|
||||
.expect("serializable generated voice p2p config"),
|
||||
voice_p2p
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
captcha
|
||||
@@ -1059,6 +1134,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("VoiceP2pConfigResponse", voice_p2p),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
@@ -1093,4 +1169,25 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_p2p_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceP2pConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceP2pConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceP2pConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,3 @@ pub struct CreateVoiceServerResponse {
|
||||
pub struct UpdateVoiceServerResponse {
|
||||
pub server: VoiceServer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GetVoiceServerResponse {
|
||||
pub server: Option<VoiceServer>,
|
||||
}
|
||||
|
||||
@@ -41,16 +41,6 @@ impl AdminApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_users(None, None, None, None, None, Some(query), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.users.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn lookup_users_by_ids(&self, user_ids: &[String]) -> ApiResult<Vec<AdminUser>> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::api::generated::types as generated_types;
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
CreateVoiceRegionResponse, CreateVoiceServerResponse, DeleteVoiceResponse,
|
||||
GetVoiceRegionResponse, GetVoiceServerResponse, ListVoiceRegionsResponse,
|
||||
ListVoiceServersResponse, UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
GetVoiceRegionResponse, ListVoiceRegionsResponse, ListVoiceServersResponse,
|
||||
UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -83,19 +83,6 @@ impl AdminApiClient {
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_voice_server(
|
||||
&self,
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<GetVoiceServerResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn create_voice_server(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
|
||||
@@ -10,7 +10,6 @@ const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrob
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminConfig {
|
||||
pub env: RuntimeEnv,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub secret_key_base: String,
|
||||
@@ -35,13 +34,6 @@ pub struct ProxyConfig {
|
||||
pub client_ip_header_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimeEnv {
|
||||
Development,
|
||||
Production,
|
||||
Test,
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
|
||||
@@ -60,7 +52,6 @@ impl AdminConfig {
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
|
||||
port: read_env("FLUXER_ADMIN_PORT", "3020")
|
||||
.parse()
|
||||
@@ -105,14 +96,6 @@ impl AdminConfig {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_dev(&self) -> bool {
|
||||
self.env == RuntimeEnv::Development
|
||||
}
|
||||
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
@@ -189,16 +172,6 @@ fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
|
||||
&& !bucket.ends_with('-')
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
pub(crate) fn from_env_value(value: &str) -> Self {
|
||||
match value {
|
||||
"production" => Self::Production,
|
||||
"test" => Self::Test,
|
||||
_ => Self::Development,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,8 +180,7 @@ mod tests {
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 15] = [
|
||||
"FLUXER_ENV",
|
||||
const MANAGED_ENV: [&str; 14] = [
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
@@ -243,135 +215,6 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_strips_trailing_slashes() {
|
||||
assert_eq!(normalize_base_path("admin/"), "/admin");
|
||||
assert_eq!(normalize_base_path("admin///"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_adds_leading_slash() {
|
||||
assert_eq!(normalize_base_path("admin"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_empty_stays_empty() {
|
||||
assert_eq!(normalize_base_path(""), "");
|
||||
assert_eq!(normalize_base_path(" "), "");
|
||||
assert_eq!(normalize_base_path("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_preserves_inner() {
|
||||
assert_eq!(normalize_base_path("/foo/bar/"), "/foo/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_removes_trailing() {
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com/"),
|
||||
"https://example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com"),
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_empty_string() {
|
||||
assert_eq!(trim_trailing_slash(""), "");
|
||||
assert_eq!(trim_trailing_slash("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_from_env_value() {
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("production"),
|
||||
RuntimeEnv::Production
|
||||
);
|
||||
assert_eq!(RuntimeEnv::from_env_value("test"), RuntimeEnv::Test);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("development"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("anything"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_production_returns_true_for_production() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_production());
|
||||
assert!(!config.is_dev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dev_returns_true_for_development() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Development,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_dev());
|
||||
assert!(!config.is_production());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_uses_defaults() {
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.env, RuntimeEnv::Development);
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3020);
|
||||
assert_eq!(config.oauth_client_id, DEFAULT_ADMIN_OAUTH_CLIENT_ID);
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
"https://admin.fluxer.app/oauth2_callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_public_endpoints() {
|
||||
let config = config_from_env(&[
|
||||
|
||||
@@ -13,43 +13,6 @@ pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stylesheet_is_served_and_content_hashed() {
|
||||
let (content_type, bytes) =
|
||||
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
|
||||
assert_eq!(content_type, "text/css; charset=utf-8");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
assert!(css.contains("font-family: 'Fluxer Sans'"));
|
||||
assert!(css.contains("font-family: 'Fluxer Mono'"));
|
||||
assert!(
|
||||
!css.contains("?v="),
|
||||
"content hashing replaces cache-bust tokens"
|
||||
);
|
||||
assert!(
|
||||
!css.contains("fluxerstatic"),
|
||||
"fonts must not be fetched from the static CDN"
|
||||
);
|
||||
assert!(
|
||||
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
|
||||
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_face_the_stylesheet_references_is_served() {
|
||||
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
let mut referenced = 0;
|
||||
for fragment in css.split("url('").skip(1) {
|
||||
let file_name = fragment.split('\'').next().expect("unterminated url()");
|
||||
let (content_type, _) = asset(file_name)
|
||||
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
|
||||
assert_eq!(content_type, "font/woff2");
|
||||
referenced += 1;
|
||||
}
|
||||
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ofl_attribution_ships_with_the_binaries() {
|
||||
let notice = ASSETS
|
||||
@@ -63,10 +26,4 @@ mod tests {
|
||||
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_files_are_not_served() {
|
||||
assert!(asset("fonts.css").is_none());
|
||||
assert!(asset("../../../etc/passwd").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +198,13 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::config::{AdminConfig, ProxyConfig};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
|
||||
@@ -108,66 +108,3 @@ pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool
|
||||
response.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize_then_parse_round_trips() {
|
||||
let flash = FlashData::success("Item saved");
|
||||
let encoded = serialize_flash(&flash);
|
||||
let decoded = parse_flash(&encoded).expect("must parse");
|
||||
assert_eq!(decoded.message, "Item saved");
|
||||
assert_eq!(decoded.flash_type, "success");
|
||||
assert!(decoded.detail.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_base64() {
|
||||
assert!(parse_flash("!!!not-base64!!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_json() {
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json");
|
||||
assert!(parse_flash(&encoded).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_success_sets_correct_type() {
|
||||
let f = FlashData::success("ok");
|
||||
assert_eq!(f.flash_type, "success");
|
||||
assert_eq!(f.message, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_error_sets_correct_type() {
|
||||
let f = FlashData::error("fail");
|
||||
assert_eq!(f.flash_type, "error");
|
||||
assert_eq!(f.message, "fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_info_sets_correct_type() {
|
||||
let f = FlashData::info("note");
|
||||
assert_eq!(f.flash_type, "info");
|
||||
assert_eq!(f.message, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_flash_message_maps_levels() {
|
||||
assert!(matches!(
|
||||
FlashData::success("").to_flash_message().level,
|
||||
FlashLevel::Success
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::error("").to_flash_message().level,
|
||||
FlashLevel::Error
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::info("").to_flash_message().level,
|
||||
FlashLevel::Info
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,19 @@ use crate::{
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -220,6 +221,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_voice_p2p" => match build_voice_p2p_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_captcha" => match build_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -557,6 +562,32 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_experiment_country_codes(value: &str) -> Result<Vec<String>, String> {
|
||||
let mut codes: Vec<String> = Vec::new();
|
||||
let candidates = value
|
||||
.split(|character: char| character == ',' || character.is_whitespace())
|
||||
.filter(|candidate| !candidate.is_empty());
|
||||
for (index, candidate) in candidates.enumerate() {
|
||||
if candidate.len() != 2 || !candidate.bytes().all(|byte| byte.is_ascii_alphabetic()) {
|
||||
return Err(format!(
|
||||
"Rollout countries entry {} must be a two-letter country code",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let code = candidate.to_ascii_uppercase();
|
||||
if codes.contains(&code) {
|
||||
continue;
|
||||
}
|
||||
if codes.len() == EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES {
|
||||
return Err(format!(
|
||||
"Rollout countries must contain at most {EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES} unique codes"
|
||||
));
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
@@ -579,6 +610,10 @@ fn build_domain_migration_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("domain_migration_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
@@ -609,6 +644,50 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_voice_p2p_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_p2p: Some(VoiceP2pConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_p2p_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_p2p_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("voice_p2p_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_p2p_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("voice_p2p_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_p2p_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
max_participants: parse_form_number(
|
||||
form,
|
||||
"voice_p2p_max_participants",
|
||||
"Maximum participants",
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
captcha: Some(CaptchaConfigUpdateRequest {
|
||||
@@ -1340,10 +1419,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_splits_uppercases_and_dedupes() {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" se ,NO\nbr\r\n fi\tSE ,, no ").expect("valid codes"),
|
||||
vec![
|
||||
"SE".to_owned(),
|
||||
"NO".to_owned(),
|
||||
"BR".to_owned(),
|
||||
"FI".to_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" \n ").expect("valid codes"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_malformed_codes() {
|
||||
for value in ["S", "SWE", "S1", "5E", "<>", "ÅL"] {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("SE,{value}")).expect_err("invalid code"),
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_exceeding_the_cap() {
|
||||
let codes = (b'A'..=b'Z')
|
||||
.flat_map(|first| {
|
||||
(b'A'..=b'Z').map(move |second| format!("{}{}", first as char, second as char))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let at_cap = codes[..EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES].join(" ");
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("{at_cap} aa"))
|
||||
.expect("valid codes at cap")
|
||||
.len(),
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!(
|
||||
"{at_cap} {}",
|
||||
codes[EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES]
|
||||
))
|
||||
.expect_err("too many codes"),
|
||||
"Rollout countries must contain at most 250 unique codes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true&domain_migration_rollout_country_codes=se%2C%20NO%0Ase",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
@@ -1369,6 +1500,10 @@ mod tests {
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
@@ -1386,6 +1521,7 @@ mod tests {
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
@@ -1430,6 +1566,10 @@ mod tests {
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
@@ -1519,6 +1659,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_p2p_enabled=true&voice_p2p_rollout_basis_points=%20500%20&voice_p2p_rollout_salt=%20voice-p2p-v2%20&voice_p2p_included_user_ids=1500000000000000001&voice_p2p_excluded_user_ids=1500000000000000002&voice_p2p_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&voice_p2p_include_premium_users=true&voice_p2p_rollout_country_codes=se%2C%20NO%0Ase&voice_p2p_max_participants=%203%20",
|
||||
);
|
||||
let update = build_voice_p2p_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_p2p
|
||||
.expect("voice p2p update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("voice-p2p-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(update.max_participants, Some(3));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_p2p_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_p2p": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_p2p_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_p2p_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_p2p_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=5",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=1",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_p2p_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -127,181 +127,3 @@ fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
_ => 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
|
||||
}]
|
||||
});
|
||||
|
||||
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()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
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),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[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.webhook_id, 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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,94 +51,3 @@ pub fn report_category(category: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,20 +74,3 @@ pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::HintLink;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "anchor")]
|
||||
fn rejects_a_link_that_points_at_no_anchor() {
|
||||
let _ = HintLink::new("/instance-config", "Instance policy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "label")]
|
||||
fn rejects_a_link_with_no_label() {
|
||||
let _ = HintLink::new("/instance-config#community-creation", " ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,31 +16,3 @@ pub fn format_user_display(
|
||||
_ => "Unknown".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::user_tag::sync_with_unique_usernames;
|
||||
|
||||
#[test]
|
||||
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice"
|
||||
);
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("helper"), Some("4363"), true),
|
||||
"helper#4363"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_instances_keep_the_zero_tag() {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice#0000"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,38 +103,3 @@ pub fn user_profile_badges(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
|
||||
user_profile_badges(
|
||||
"https://static.example.com",
|
||||
0,
|
||||
Some(premium_type),
|
||||
Some("2026-01-01"),
|
||||
self_hosted,
|
||||
name,
|
||||
false,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_premium_badges_keep_their_fluxer_labels() {
|
||||
assert!(
|
||||
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
|
||||
);
|
||||
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_premium_badges_use_the_configured_name() {
|
||||
let markup = render(true, Some("Gold"), 1);
|
||||
assert!(markup.contains("Gold subscriber since 2026-01-01"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
|
||||
assert!(!render(true, None, 1).contains("img"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ pub fn render_sidebar(
|
||||
nav data-sidebar-nav="" class="sidebar-scrollbar flex-1 space-y-4 overflow-y-auto p-4" aria-label="Admin sections" {
|
||||
@for section in NAV_SECTIONS {
|
||||
@let visible_items: Vec<_> = section.items.iter()
|
||||
.filter(|item| !(item.hosted_only && config.self_hosted))
|
||||
.filter(|item| acl::has_any_permission(admin_acls, item.required_acls))
|
||||
.filter(|item| {
|
||||
item.active_key != "voice-servers" || inspected_voice_region_id.is_some()
|
||||
|
||||
@@ -7,7 +7,6 @@ pub struct NavItem {
|
||||
pub path: &'static str,
|
||||
pub active_key: &'static str,
|
||||
pub required_acls: &'static [&'static str],
|
||||
pub hosted_only: bool,
|
||||
}
|
||||
|
||||
pub struct NavSection {
|
||||
@@ -17,10 +16,7 @@ pub struct NavSection {
|
||||
|
||||
macro_rules! item {
|
||||
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ]) => {
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: false }
|
||||
};
|
||||
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ], hosted) => {
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: true }
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,26 +249,3 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
|
||||
let item = NAV_SECTIONS
|
||||
.iter()
|
||||
.flat_map(|section| section.items)
|
||||
.find(|item| item.active_key == "bulk-actions")
|
||||
.expect("bulk actions nav item");
|
||||
for required in [
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
] {
|
||||
assert!(item.required_acls.contains(&required), "{required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,48 +171,3 @@ pub fn audit_logs_page(
|
||||
};
|
||||
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_type_filter_offers_bulk_jobs() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "",
|
||||
target_type: "bulk_job",
|
||||
access: "",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_filter_survives_form_and_pagination() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "1500000000000000001",
|
||||
target_type: "",
|
||||
access: "read",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<select id="access" name="access""#));
|
||||
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
|
||||
assert_eq!(
|
||||
build_pagination_url("/admin", 1, ¶ms),
|
||||
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,58 +326,3 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"log_id": "1900000000000000001",
|
||||
"admin_user_id": "1500000000000000001",
|
||||
"action": "bulk_schedule_deletion",
|
||||
"target_id": target_id,
|
||||
"target_type": target_type,
|
||||
"audit_log_reason": "Raid cleanup",
|
||||
"created_at": "2026-09-14T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_job_targets_link_to_the_job_detail_page() {
|
||||
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
|
||||
assert!(markup.contains("Bulk job"));
|
||||
assert!(!markup.contains("/admin/users/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_labels_drop_the_zero_tag_only_without_tags() {
|
||||
let admin = AuditLogUserSummary {
|
||||
id: "1500000000000000001".to_owned(),
|
||||
username: "lilith".to_owned(),
|
||||
discriminator: "0".to_owned(),
|
||||
global_name: Some("Lilith".to_owned()),
|
||||
};
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith#0000)");
|
||||
crate::utils::user_tag::sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith)");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_target_types_stay_unlinked() {
|
||||
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||
assert!(!markup.contains("<a "));
|
||||
assert!(markup.contains("Email domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_action_names_still_render() {
|
||||
let mut retired = entry("user", "1500000000000000002");
|
||||
retired.action = "update_retired_toggle".to_string();
|
||||
let markup = audit_log_table_body("/admin", &[retired]).into_string();
|
||||
assert!(markup.contains("Update retired toggle"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,30 +418,6 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
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 deletion_form_has_no_preselected_reason() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||
for (value, _) in DELETION_REASONS {
|
||||
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_emails_each_user_by_default() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
@@ -456,13 +432,4 @@ mod tests {
|
||||
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[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""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,39 +139,3 @@ pub fn gift_codes_page(
|
||||
};
|
||||
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
|
||||
PremiumBranding {
|
||||
name: Some(name.to_owned()),
|
||||
premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
|
||||
let hosted = GiftCodesPremium::from_branding(false, None);
|
||||
assert_eq!(hosted.name, "Plutonium");
|
||||
assert!(!hosted.needs_mirror_mode);
|
||||
let self_hosted = GiftCodesPremium::from_branding(true, None);
|
||||
assert_eq!(self_hosted.name, "Premium");
|
||||
assert!(!self_hosted.needs_mirror_mode);
|
||||
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
|
||||
assert_eq!(gold.name, "Gold");
|
||||
assert!(!gold.needs_mirror_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
|
||||
assert!(
|
||||
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
|
||||
);
|
||||
assert!(
|
||||
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
|
||||
.needs_mirror_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,56 +182,3 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,49 +110,3 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,32 +294,3 @@ fn readonly_field(label: &str, value: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn guild() -> GuildDetailInfo {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1500000000000000001",
|
||||
"owner_id": "1400000000000000001",
|
||||
"name": "Guild",
|
||||
"verification_level": 1
|
||||
}))
|
||||
.expect("valid guild detail")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_claimed_account_in_username_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), true).into_string();
|
||||
assert!(markup.contains("Low (claimed account)"));
|
||||
assert!(!markup.contains("verified email"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_verified_email_in_email_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), false).into_string();
|
||||
assert!(markup.contains("Low (verified email)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,189 +464,3 @@ pub fn premium_billing_section(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn operator_billing() -> InstanceBillingResponse {
|
||||
InstanceBillingResponse {
|
||||
enabled: Some(true),
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
stripe_secret_key_stored: true,
|
||||
stripe_webhook_secret_stored: true,
|
||||
default_currency: Some("GBP".to_owned()),
|
||||
prices: Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1GbpM".to_owned()),
|
||||
yearly: Some("price_1GbpY".to_owned()),
|
||||
gift_1_month: None,
|
||||
gift_1_year: Some("price_1GbpG".to_owned()),
|
||||
},
|
||||
)])),
|
||||
country_currencies: Some(BTreeMap::from([
|
||||
("GB".to_owned(), "GBP".to_owned()),
|
||||
("IE".to_owned(), "GBP".to_owned()),
|
||||
])),
|
||||
legacy_prices: Some(BTreeMap::from([(
|
||||
"monthly_GBP".to_owned(),
|
||||
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
|
||||
)])),
|
||||
billing_active: true,
|
||||
stripe_serviceable: true,
|
||||
catalog_mode: BillingCatalogMode::Operator,
|
||||
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
|
||||
automatic_tax: None,
|
||||
tax_id_collection: Some(true),
|
||||
terms_consent_required: Some(false),
|
||||
effective_automatic_tax: false,
|
||||
effective_tax_id_collection: true,
|
||||
effective_terms_consent_required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn branding(name: &str) -> AppBrandingConfigResponse {
|
||||
AppBrandingConfigResponse {
|
||||
premium_product_name: name.to_owned(),
|
||||
premium_info_url: Some("https://example.com/gold".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_renders_every_field_and_one_empty_price_row() {
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Gold"),
|
||||
&operator_billing(),
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
|
||||
assert!(markup.contains("data-admin-result-form=\"true\""));
|
||||
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(markup.contains("name=\"billing_automatic_tax\""));
|
||||
assert!(markup.contains("name=\"billing_tax_id_collection\""));
|
||||
assert!(markup.contains("name=\"billing_terms_consent_required\""));
|
||||
assert!(markup.contains("Customer portal"));
|
||||
assert!(markup.contains("name=\"billing_premium_product_name\""));
|
||||
assert!(markup.contains("value=\"Gold\""));
|
||||
assert!(markup.contains("value=\"https://example.com/gold\""));
|
||||
assert!(markup.contains("name=\"billing_enabled\""));
|
||||
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
|
||||
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
|
||||
assert!(markup.contains("Billing active"));
|
||||
assert!(!markup.contains("Purchases are unavailable"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
|
||||
assert_eq!(
|
||||
markup.matches("name=\"billing_price_gift_1_year\"").count(),
|
||||
2
|
||||
);
|
||||
assert!(markup.contains("value=\"price_1GbpG\""));
|
||||
assert!(markup.contains("GB=GBP\nIE=GBP"));
|
||||
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(!markup.contains("sk_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_secrets_have_no_clear_checkbox() {
|
||||
let billing = InstanceBillingResponse::default();
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Everyone,
|
||||
)
|
||||
.into_string();
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
|
||||
assert!(markup.contains("Switch the premium model to Mirror"));
|
||||
assert!(markup.contains("Catalog: environment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_secrets_are_labelled_and_cannot_be_cleared() {
|
||||
let billing = InstanceBillingResponse {
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
..Default::default()
|
||||
};
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("Stripe secret key from environment"));
|
||||
assert!(markup.contains("Webhook secret from environment"));
|
||||
assert!(markup.contains("Set from the environment"));
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_selects_reflect_the_stored_value() {
|
||||
let render = |stored| {
|
||||
tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
stored,
|
||||
"",
|
||||
)
|
||||
.into_string()
|
||||
};
|
||||
assert!(
|
||||
render(None)
|
||||
.contains("<option value=\"default\" selected>Use environment setting</option>")
|
||||
);
|
||||
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockers_explain_why_billing_is_inactive() {
|
||||
let mut billing = InstanceBillingResponse::default();
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Everyone),
|
||||
vec![
|
||||
"the premium model is Everyone, so there is no paid tier to sell",
|
||||
"billing is not enabled",
|
||||
"no Stripe secret key is set",
|
||||
]
|
||||
);
|
||||
billing.effective_enabled = true;
|
||||
billing.stripe_secret_key_set = true;
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec![
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
]
|
||||
);
|
||||
billing.catalog_mode = BillingCatalogMode::Operator;
|
||||
billing.prices = Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1A".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec!["no currency has both a monthly and a yearly price ID"]
|
||||
);
|
||||
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ use crate::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -58,6 +59,22 @@ fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn experiment_rollout_country_codes_field(name: &str, country_codes: &[String]) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(name, "Rollout Countries", &country_codes.join(", "), "SE, NO, FI"))
|
||||
(entry_count_hint(country_codes.len(), EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Two-letter ISO 3166-1 country codes, separated by commas or spaces. When set, \
|
||||
the user rollout percentage only applies to requests from these countries, and \
|
||||
a request with an unknown country is left out. Leave empty to apply the \
|
||||
percentage everywhere. The always-on lists, the premium switch and the \
|
||||
never-on list ignore the country. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn number_field(
|
||||
name: &str,
|
||||
label: &str,
|
||||
@@ -193,6 +210,7 @@ pub fn instance_config_page(
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(voice_p2p_section(base, csrf_token, &instance_config.voice_p2p))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1193,6 +1211,10 @@ fn domain_migration_section(
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
(experiment_rollout_country_codes_field(
|
||||
"domain_migration_rollout_country_codes",
|
||||
&domain_migration.rollout_country_codes,
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"domain_migration_rollout_salt",
|
||||
@@ -1287,6 +1309,162 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_p2p_section(base: &str, csrf_token: &str, voice_p2p: &VoiceP2pConfigResponse) -> Markup {
|
||||
let status = if voice_p2p.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = voice_p2p.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_p2p.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Peer-to-peer voice",
|
||||
"Lets selected users start small voice calls that connect the participants directly \
|
||||
instead of through the voice servers. A peer-to-peer call only moves to the voice \
|
||||
servers when a participant switches it or its region changes.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_p2p"} {
|
||||
(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_p2p.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_p2p_enabled",
|
||||
"true",
|
||||
"Let the selected users start peer-to-peer calls",
|
||||
voice_p2p.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every new \
|
||||
call uses the voice servers and a running peer-to-peer call accepts no \
|
||||
new joins, so the rollout and targeting fields below have no effect at \
|
||||
all. While it is on, the fields below select who can start a \
|
||||
peer-to-peer call, and anyone who agrees to a direct connection can \
|
||||
join one."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Call size" }
|
||||
(number_field(
|
||||
"voice_p2p_max_participants",
|
||||
"Maximum participants",
|
||||
&voice_p2p.max_participants.to_string(),
|
||||
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start()),
|
||||
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end()),
|
||||
"1",
|
||||
Some("Most participants a peer-to-peer call can hold, 2 through 4. Clients only offer a peer-to-peer call in a direct message or group with at most this many members, and a join past it is refused as full. Lowering it never removes anyone from a running call."),
|
||||
))
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_p2p_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_p2p.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users who can start peer-to-peer calls, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(experiment_rollout_country_codes_field(
|
||||
"voice_p2p_rollout_country_codes",
|
||||
&voice_p2p.rollout_country_codes,
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_p2p_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_p2p.rollout_salt,
|
||||
VOICE_P2P_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"voice_p2p_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
voice_p2p.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&voice_p2p.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_p2p_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_p2p.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Peer-to-Peer Voice Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
|
||||
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
|
||||
}
|
||||
@@ -1964,186 +2142,3 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_instances_hide_email_delivery_and_the_smtp_test() {
|
||||
let integrations = InstanceIntegrationsResponse::default();
|
||||
let username = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Username,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(!username.contains("Email delivery"));
|
||||
assert!(!username.contains("test_smtp"));
|
||||
assert!(!username.contains("integration_email_present"));
|
||||
assert!(username.contains("Bluesky OAuth"));
|
||||
|
||||
let email = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Email,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(email.contains("Email delivery"));
|
||||
assert!(email.contains("test_smtp"));
|
||||
assert!(email.contains(r#"name="integration_email_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_has_no_tag_choice_in_username_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Username,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Sign-in Method"));
|
||||
assert!(markup.contains("Username"));
|
||||
assert!(markup.contains("Fixed"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("username tags"));
|
||||
assert!(!markup.contains("<form"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_random_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Random tags"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(markup.contains("username tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_no_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_the_lock_state_only_when_known() {
|
||||
let render = |locked| {
|
||||
account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked,
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string()
|
||||
};
|
||||
let unlocked = render(Some(false));
|
||||
assert!(unlocked.contains("Not fixed yet"));
|
||||
let unknown = render(None);
|
||||
assert!(!unknown.contains("Fixed"));
|
||||
assert!(!unknown.contains("Not fixed yet"));
|
||||
assert!(unknown.contains("Random tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captcha_section_posts_the_switch_and_difficulty_fields() {
|
||||
let markup =
|
||||
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
|
||||
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
|
||||
assert!(markup.contains(r#"name="captcha_enabled""#));
|
||||
assert!(markup.contains(r#"name="captcha_cost""#));
|
||||
assert!(markup.contains(r#"name="captcha_max_counter""#));
|
||||
assert!(markup.contains("about 3.6 s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
|
||||
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
|
||||
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
};
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_mode_options_use_the_configured_premium_name() {
|
||||
let markup =
|
||||
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
|
||||
.into_string();
|
||||
assert!(markup.contains("Mirror (Free and Gold tiers)"));
|
||||
assert!(markup.contains("Everyone (every member gets Gold limits)"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_flags_a_list_at_its_cap() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,22 +196,3 @@ pub(crate) fn next_page_link(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::status_badge;
|
||||
|
||||
#[test]
|
||||
fn status_badges_use_us_spelling_and_readable_labels() {
|
||||
let canceled = status_badge("cancelled").into_string();
|
||||
assert!(canceled.contains(">Canceled<"));
|
||||
assert!(!canceled.contains("Cancelled"));
|
||||
assert!(
|
||||
status_badge("deadletter")
|
||||
.into_string()
|
||||
.contains(">Dead-letter<")
|
||||
);
|
||||
assert!(status_badge("running").into_string().contains(">Running<"));
|
||||
assert!(status_badge("mystery").into_string().contains(">mystery<"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -640,126 +640,3 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
.join("&");
|
||||
format!("{}/reports?{}", config.base_path, query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> ReportEntry {
|
||||
let mut value = json!({
|
||||
"report_id": "1556709309709027556",
|
||||
"reporter_id": "1556709306000000001",
|
||||
"reporter_username": "reporter_80f33d09e88a",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "5193",
|
||||
"reported_at": "2026-10-06T04:33:00Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1556709306000000002",
|
||||
"reported_user_username": "target_bc57b33ca5c4",
|
||||
"reported_user_discriminator": "8316"
|
||||
});
|
||||
for key in [
|
||||
"reporter_tag",
|
||||
"reporter_email",
|
||||
"reporter_full_legal_name",
|
||||
"reporter_country_of_residence",
|
||||
"additional_info",
|
||||
"reported_user_tag",
|
||||
"reported_user_global_name",
|
||||
"reported_user_avatar_hash",
|
||||
"reported_guild_id",
|
||||
"reported_guild_name",
|
||||
"reported_guild_icon_hash",
|
||||
"reported_message_id",
|
||||
"reported_channel_id",
|
||||
"reported_channel_name",
|
||||
"reported_channel_nsfw",
|
||||
"reported_guild_invite_code",
|
||||
"reported_guild_nsfw_level",
|
||||
"reported_guild_nsfw",
|
||||
"reported_guild_content_warning_level",
|
||||
"reported_guild_content_warning_text",
|
||||
"reported_channel_nsfw_override",
|
||||
"reported_channel_content_warning_level",
|
||||
"reported_channel_content_warning_text",
|
||||
"reported_channel_effective_nsfw",
|
||||
"reported_channel_effective_content_warning_level",
|
||||
"reported_channel_effective_content_warning_text",
|
||||
"resolved_at",
|
||||
"resolved_by_admin_id",
|
||||
"public_comment",
|
||||
"mutual_dm_channel_id",
|
||||
"message_context",
|
||||
] {
|
||||
value[key] = serde_json::Value::Null;
|
||||
}
|
||||
serde_json::from_value(value).expect("report fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_layout_moves_date_and_status_into_visible_columns() {
|
||||
let markup = render_reports_table(&test_config(), &[report()]).into_string();
|
||||
assert!(markup.contains(
|
||||
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
|
||||
));
|
||||
assert!(markup.contains(
|
||||
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
|
||||
));
|
||||
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap xl:table-cell")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(
|
||||
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("[overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,47 +173,3 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,56 +764,6 @@ mod tests {
|
||||
serde_json::from_value(value).expect("valid admin user")
|
||||
}
|
||||
|
||||
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1400000000000000001",
|
||||
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
|
||||
"action": action,
|
||||
"target_id": "1500000000000000001",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": reason,
|
||||
"metadata": metadata,
|
||||
"created_at": "2026-09-01T10:00:00.000Z"
|
||||
}))
|
||||
.expect("valid audit log entry")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
|
||||
let target = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3,
|
||||
"deletion_public_reason": "Spam",
|
||||
"deletion_audit_log_reason": "Report batch 12",
|
||||
"deletion_scheduled_by": "1400000000000000001",
|
||||
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
|
||||
}));
|
||||
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
|
||||
let markup =
|
||||
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
|
||||
assert!(markup.contains("lilith"));
|
||||
assert!(markup.contains("Report batch 12"));
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
|
||||
)
|
||||
);
|
||||
assert!(markup.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
|
||||
assert!(markup.contains(r#"name="private_reason" required"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_makes_the_reason_an_explicit_choice() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
|
||||
assert!(!markup.contains(r#"<option value="1" selected>"#));
|
||||
assert!(!markup.contains("replace_pending_deletion_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_emails_the_user_by_default() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
@@ -864,49 +814,4 @@ mod tests {
|
||||
));
|
||||
assert!(markup.contains(r#"name="private_reason""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let logs = vec![
|
||||
entry(
|
||||
"3",
|
||||
"annotate_ban",
|
||||
"Also sent links",
|
||||
json!({"ban_audit_log_id": "2"}),
|
||||
),
|
||||
entry(
|
||||
"2",
|
||||
"temp_ban",
|
||||
"Regel § 3",
|
||||
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"1",
|
||||
"temp_ban",
|
||||
"Older ban",
|
||||
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"4",
|
||||
"annotate_ban",
|
||||
"Old note",
|
||||
json!({"ban_audit_log_id": "1"}),
|
||||
),
|
||||
];
|
||||
let ban = find_current_ban(&target, &logs).expect("current ban");
|
||||
assert_eq!(ban.entry.log_id, "2");
|
||||
assert_eq!(
|
||||
ban.notes
|
||||
.iter()
|
||||
.map(|note| note.log_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["3"]
|
||||
);
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
|
||||
assert!(markup.contains("Regel § 3"));
|
||||
assert!(markup.contains("Also sent links"));
|
||||
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
|
||||
assert!(markup.contains("?action=annotate_ban&tab=moderation"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,84 +584,3 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: crate::config::RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: true,
|
||||
proxy: crate::config::ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_email_row(username_sign_in: bool) -> String {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"email": "[email protected]"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
|
||||
render_overview_tab(
|
||||
&test_config(),
|
||||
&user,
|
||||
&acls,
|
||||
"csrf",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
username_sign_in,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_the_email_row() {
|
||||
assert!(!render_email_row(true).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_shows_the_email_row() {
|
||||
assert!(render_email_row(false).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_active_and_grace_end_use_the_panel_date_format() {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"last_active_at": "2026-10-06T20:23:38.591Z",
|
||||
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let html =
|
||||
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
|
||||
.into_string();
|
||||
assert!(!html.contains("2026-10-06T20:23"));
|
||||
assert!(!html.contains("2026-11-01T08:00"));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,168 +375,3 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
html! {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1700000000000000500",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_guild_id": "1700000000000000800",
|
||||
"reported_guild_name": "Harbor"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let config = test_config();
|
||||
let sent = report_row(&config, "/admin", "sent", &report).into_string();
|
||||
assert!(
|
||||
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains(r#"data-report-webhook="1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(sent.contains("Harbor Bulletin"), "{sent}");
|
||||
assert!(!sent.contains("/admin/guilds/"), "{sent}");
|
||||
let received = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert!(!received.contains("data-report-webhook"), "{received}");
|
||||
assert!(
|
||||
received.contains(r#"href="/admin/users/1500000000000000000""#),
|
||||
"{received}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_label_the_category_and_keep_the_key() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000005",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "inappropriate_profile",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
for kind in ["sent", "received"] {
|
||||
let markup = report_row(&config, "/admin", kind, &report).into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="inappropriate_profile""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
|
||||
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-reason="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(r#"href="/admin/reports/1800000000000000005""#)
|
||||
.count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(!markup.contains(">View<"), "{markup}");
|
||||
}
|
||||
let mut unknown = report.clone();
|
||||
unknown.category = Some("future_value".to_owned());
|
||||
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
|
||||
assert!(markup.contains(">future_value<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_skip_a_reason_that_repeats_the_category() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000007",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert_eq!(
|
||||
markup.matches("Harassment or bullying").count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup.matches(r#"data-report-reason="harassment""#).count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Message<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,25 +459,3 @@ fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
|
||||
pairs.push(format!("page={page}"));
|
||||
format!("{base}/users?{}", pairs.join("&"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params() -> UserListParams {
|
||||
UserListParams::from_query(None, None, None, None, None, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_has_no_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), false).into_string();
|
||||
assert!(!markup.contains("search-email"));
|
||||
assert!(markup.contains("search-q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_keeps_the_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), true).into_string();
|
||||
assert!(markup.contains("search-email"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,3 @@ pub fn format_discriminator(discriminator: &str) -> String {
|
||||
let num: u16 = discriminator.parse().unwrap_or(0);
|
||||
format!("{num:04}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_discriminator_pads_short() {
|
||||
assert_eq!(format_discriminator("1"), "0001");
|
||||
assert_eq!(format_discriminator("42"), "0042");
|
||||
assert_eq!(format_discriminator("0"), "0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discriminator_four_digits() {
|
||||
assert_eq!(format_discriminator("1234"), "1234");
|
||||
assert_eq!(format_discriminator("9999"), "9999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discriminator_invalid_input() {
|
||||
assert_eq!(format_discriminator(""), "0000");
|
||||
assert_eq!(format_discriminator("abc"), "0000");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,73 +128,3 @@ impl MultiValueForm {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clean_string_trims_whitespace() {
|
||||
assert_eq!(clean_string(" hello "), Some("hello".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_string_returns_none_for_empty() {
|
||||
assert_eq!(clean_string(""), None);
|
||||
assert_eq!(clean_string(" "), None);
|
||||
assert_eq!(clean_string("\t\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_string_preserves_inner_spaces() {
|
||||
assert_eq!(
|
||||
clean_string(" hello world "),
|
||||
Some("hello world".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_comma_separated_splits() {
|
||||
assert_eq!(parse_comma_separated("a, b, c"), vec!["a", "b", "c"]);
|
||||
assert_eq!(parse_comma_separated("a\nb\r\nc"), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_comma_separated_filters_empty() {
|
||||
assert_eq!(parse_comma_separated("a,,b, ,c"), vec!["a", "b", "c"]);
|
||||
assert!(parse_comma_separated("").is_empty());
|
||||
assert!(parse_comma_separated(", , ,").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_value_form_preserves_repeated_fields() {
|
||||
let form = MultiValueForm::parse(b"fields%5B%5D=avatar&fields%5B%5D=banner&name=Test");
|
||||
|
||||
assert_eq!(
|
||||
form.list_values("fields[]"),
|
||||
vec!["avatar".to_owned(), "banner".to_owned()]
|
||||
);
|
||||
assert_eq!(form.clean("name"), Some("Test".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_value_form_list_values_accepts_comma_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(b"acls=user.read,user.write&acls=guild.read");
|
||||
|
||||
assert_eq!(
|
||||
form.list_values("acls"),
|
||||
vec![
|
||||
"user.read".to_owned(),
|
||||
"user.write".to_owned(),
|
||||
"guild.read".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_value_form_bool_value_accepts_html_checkbox_values() {
|
||||
assert!(MultiValueForm::parse(b"enabled=on").bool_value("enabled"));
|
||||
assert!(MultiValueForm::parse(b"enabled=true").bool_value("enabled"));
|
||||
assert!(!MultiValueForm::parse(b"enabled=false").bool_value("enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,3 @@ pub fn count_noun(count: u64, singular: &str, plural: &str) -> String {
|
||||
pub fn noun_for(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
|
||||
if count == 1 { singular } else { plural }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn count_noun_picks_the_singular_only_for_one() {
|
||||
assert_eq!(count_noun(0, "result", "results"), "0 results");
|
||||
assert_eq!(count_noun(1, "result", "results"), "1 result");
|
||||
assert_eq!(count_noun(2, "entry", "entries"), "2 entries");
|
||||
assert_eq!(noun_for(1, "item", "items"), "item");
|
||||
assert_eq!(noun_for(3, "item", "items"), "items");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,52 +54,3 @@ pub fn snowflake_creation_date(snowflake: &str) -> String {
|
||||
})
|
||||
.map_or_else(|| "Unknown".to_owned(), format_admin_datetime)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snowflake_to_timestamp_ms_known_value() {
|
||||
assert_eq!(snowflake_to_timestamp_ms("0"), Some(FLUXER_EPOCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_to_timestamp_ms_real_id() {
|
||||
let snowflake = (1u64 << 22).to_string();
|
||||
assert_eq!(
|
||||
snowflake_to_timestamp_ms(&snowflake),
|
||||
Some(FLUXER_EPOCH + 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_to_timestamp_ms_invalid() {
|
||||
assert_eq!(snowflake_to_timestamp_ms(""), None);
|
||||
assert_eq!(snowflake_to_timestamp_ms("abc"), None);
|
||||
assert_eq!(snowflake_to_timestamp_ms("-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_creation_date_invalid() {
|
||||
assert_eq!(snowflake_creation_date("abc"), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_creation_date_valid() {
|
||||
assert_eq!(snowflake_creation_date("0"), "Jan 1, 2015, 12:00 AM UTC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_timestamps_render_in_the_panel_format() {
|
||||
assert_eq!(
|
||||
format_admin_timestamp("2026-10-06T14:05:09.123Z"),
|
||||
"Oct 6, 2026, 2:05 PM UTC"
|
||||
);
|
||||
assert_eq!(
|
||||
format_admin_timestamp("2026-10-06T00:30:00+02:00"),
|
||||
"Oct 5, 2026, 10:30 PM UTC"
|
||||
);
|
||||
assert_eq!(format_admin_timestamp("not a date"), "not a date");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ pub async fn with_unique_usernames<F: Future>(unique_usernames: bool, future: F)
|
||||
UNIQUE_USERNAMES.scope(unique_usernames, future).await
|
||||
}
|
||||
|
||||
pub fn sync_with_unique_usernames<R>(unique_usernames: bool, f: impl FnOnce() -> R) -> R {
|
||||
UNIQUE_USERNAMES.sync_scope(unique_usernames, f)
|
||||
}
|
||||
|
||||
pub fn unique_usernames() -> bool {
|
||||
UNIQUE_USERNAMES.try_with(|value| *value).unwrap_or(false)
|
||||
}
|
||||
@@ -29,41 +25,3 @@ pub fn user_tag(username: &str, discriminator: &str, is_bot: bool) -> String {
|
||||
username.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_mode_keeps_every_tag() {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
|
||||
assert_eq!(user_tag("alice", "0042", false), "alice#0042");
|
||||
sync_with_unique_usernames(false, || {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
|
||||
assert_eq!(user_tag("bot", "0000", true), "bot#0000");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_zero_tag_for_humans() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_tag("alice", "0000", false), "alice");
|
||||
assert_eq!(user_tag("alice", "0", false), "alice");
|
||||
assert!(!shows_discriminator("0000", false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_keeps_bot_and_non_zero_tags() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_tag("helper", "4363", true), "helper#4363");
|
||||
assert_eq!(user_tag("helper", "0000", true), "helper#0000");
|
||||
assert_eq!(user_tag("legacy", "0042", false), "legacy#0042");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_defaults_to_email_outside_a_request() {
|
||||
assert!(!unique_usernames());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_country_codes": ["SE"],
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
@@ -416,6 +417,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"voice_p2p": {
|
||||
"enabled": true,
|
||||
"config_version": 4,
|
||||
"rollout_basis_points": 750,
|
||||
"rollout_country_codes": ["SE", "NO"],
|
||||
"rollout_salt": "voice-p2p-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": ["1500000000000000002"],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"max_participants": 3,
|
||||
"future_voice_p2p_knob": true
|
||||
},
|
||||
"captcha": {
|
||||
"enabled": true,
|
||||
"cost": 5000,
|
||||
@@ -569,10 +583,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
assert_eq!(resp.domain_migration.rollout_country_codes.len(), 1);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.voice_p2p.enabled);
|
||||
assert_eq!(resp.voice_p2p.config_version, 4);
|
||||
assert_eq!(resp.voice_p2p.rollout_basis_points, 750);
|
||||
assert_eq!(resp.voice_p2p.rollout_country_codes.len(), 2);
|
||||
assert_eq!(*resp.voice_p2p.rollout_salt, "voice-p2p-v1");
|
||||
assert_eq!(resp.voice_p2p.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.voice_p2p.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.voice_p2p.included_guild_ids.len(), 1);
|
||||
assert!(resp.voice_p2p.include_premium_users);
|
||||
assert_eq!(resp.voice_p2p.max_participants, 3);
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.captcha.enabled);
|
||||
assert_eq!(resp.captcha.max_counter, 1000);
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -85,7 +85,6 @@ async fn setup() -> TestApp {
|
||||
|
||||
fn production_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -346,7 +346,6 @@ fn admin_user() -> Value {
|
||||
|
||||
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(),
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[path = "parity/mod.rs"]
|
||||
mod parity_support;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, RawQuery},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use fluxer_admin::api::{
|
||||
generated::types::{
|
||||
AdminReportListResponse, LookupGuildResponse, ReportAdminResponseSchema,
|
||||
SearchGuildsResponse,
|
||||
},
|
||||
types::{ReportEntry, SearchReportsResponse},
|
||||
};
|
||||
use parity_support::{
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const SEARCH_REPORTS_V2: &str = include_str!("parity/fixtures/api/search_reports_v2.json");
|
||||
const SEARCH_REPORTS_WEBHOOK: &str =
|
||||
include_str!("parity/fixtures/api/search_reports_webhook.json");
|
||||
const REPORT_WEBHOOK_DETAIL: &str = include_str!("parity/fixtures/api/report_webhook_detail.json");
|
||||
const REPORT_EVIDENCE_DETAIL: &str =
|
||||
include_str!("parity/fixtures/api/report_evidence_detail.json");
|
||||
const EVIDENCE_REPORT_ID: &str = "1556352159220498613";
|
||||
const EVIDENCE_BOT_ID: &str = "1556352159149195428";
|
||||
const EVIDENCE_WEBHOOK_ID: &str = "1556352159132418208";
|
||||
const EVIDENCE_DELETED_AUTHOR_ID: &str = "1556352159216304308";
|
||||
const WEBHOOK_ID: &str = "1556114449176200401";
|
||||
const WEBHOOK_REPORT_ID: &str = "1556114449264280806";
|
||||
const BOT_REPORT_ID: &str = "1556114449268475111";
|
||||
const BOT_USER_ID: &str = "1556114449192977621";
|
||||
const WEBHOOK_CREATOR_ID: &str = "1556114449125868741";
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
|
||||
let left = r#"<form><input value="aaaaaaaa" name="_csrf" type="hidden"><svg><line x1="1" x2="2"></line></svg><a class="b" href="/static/app.css?v=123" id="x">Open</a></form>"#;
|
||||
let right = r#"<form><input type="hidden" name="_csrf" value="bbbbbbbb"/><svg><line x2="2" x1="1"/></svg><a id="x" href="/static/app.css?v=456" class="b">Open</a></form>"#;
|
||||
assert_eq!(
|
||||
html_normalizer::normalize_html(left),
|
||||
html_normalizer::normalize_html(right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_normalizer_replaces_ports_assets_query_values_and_cookie_tokens() {
|
||||
let raw = "http://127.0.0.1:31987/auth/start?state=abc&next=/static/app.css?v=dev admin_session=abcdef; csrf_token=12345; oauth_state=zz";
|
||||
let normalized = html_normalizer::normalize_text(raw);
|
||||
assert!(normalized.contains("127.0.0.1:__PORT__"));
|
||||
assert!(normalized.contains("state=__OAUTH_STATE__"));
|
||||
assert!(normalized.contains("/static/app.css"));
|
||||
assert!(normalized.contains("admin_session=__SESSION__"));
|
||||
assert!(normalized.contains("csrf_token=__CSRF_COOKIE__"));
|
||||
assert!(normalized.contains("oauth_state=__OAUTH_STATE__"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_normalizer_replaces_script_csrf_values() {
|
||||
let raw = r#"<script>(function(){var csrf="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";})()</script>"#;
|
||||
let normalized = html_normalizer::normalize_text(raw);
|
||||
assert!(normalized.contains(r#"var csrf="__CSRF_TOKEN__""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_allows_intentional_rust_markup_fixes() {
|
||||
let ts = r#"<html><head></head><body><div class="flex flex-col gap-8 items-stretch"></div><div class="flex flex-col gap-4 items-stretch"></div><script defer>window.__fluxerDrawerInit = true;</script><aside data-drawer-panel="user-peek" aria-hidden="true"></aside></body></html>"#;
|
||||
let rust = r#"<!DOCTYPE html><html><head><script src="/static/htmx.min.js?t=parity" defer></script></head><body hx-boost="true"><div class="flex flex-col gap-8 items-center"></div><div id="users-results" class="flex flex-col gap-4 items-stretch"></div><script defer>document.body.addEventListener('showFlash', function () {});</script><script defer>window.__adminCopyToClipboard = function () {};</script><aside id="user-peek" data-drawer-panel="user-peek" popover="auto"></aside></body></html>"#;
|
||||
assert_eq!(
|
||||
html_normalizer::normalize_html(ts),
|
||||
html_normalizer::normalize_html(rust)
|
||||
);
|
||||
}
|
||||
|
||||
#[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.nsfw, Some(false));
|
||||
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.nsfw, Some(false));
|
||||
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()
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(api_server.base_url())
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let cases = [
|
||||
("/dashboard", 302, Some("/users"), None),
|
||||
("/users?q=Parity", 200, None, Some("Parity User")),
|
||||
("/guilds?q=Parity", 200, None, Some("Parity Guild")),
|
||||
(
|
||||
"/guilds/1600000000000000001",
|
||||
200,
|
||||
None,
|
||||
Some("Parity Guild"),
|
||||
),
|
||||
("/reports", 200, None, Some("1700000000000000001")),
|
||||
(
|
||||
"/reports/1700000000000000001",
|
||||
200,
|
||||
None,
|
||||
Some("Report Details"),
|
||||
),
|
||||
];
|
||||
for (route, expected_status, expected_location, expected_body) in cases {
|
||||
let response =
|
||||
capture::fetch_route(&client, rust_admin.base_url(), route, Some(&session_cookie))
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(response.status, expected_status, "{route}: {response:#?}");
|
||||
if let Some(expected_location) = expected_location {
|
||||
assert_eq!(
|
||||
response.location.as_deref(),
|
||||
Some(expected_location),
|
||||
"{route}: {response:#?}"
|
||||
);
|
||||
}
|
||||
if let Some(expected_body) = expected_body {
|
||||
assert!(
|
||||
response.body.contains(expected_body),
|
||||
"{route}: expected body to contain {expected_body:?}\n{response:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_search_v2_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the generated response contract");
|
||||
let flow = generated.reports[0]
|
||||
.flow
|
||||
.as_ref()
|
||||
.expect("the v2 message report has answers");
|
||||
assert_eq!(flow.surface, "in_app");
|
||||
assert_eq!(flow.steps.len(), 4);
|
||||
assert_eq!(generated.reports[0].reason.as_deref(), Some("csam"));
|
||||
assert_eq!(generated.reports[2].reason, None);
|
||||
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the hand-written response type");
|
||||
assert_eq!(response.total, 3);
|
||||
let [message, user, legacy] = response.reports.as_slice() else {
|
||||
panic!("expected three reports");
|
||||
};
|
||||
assert_eq!(
|
||||
message.reason_label.as_deref(),
|
||||
Some("Child sexual abuse material")
|
||||
);
|
||||
assert_eq!(message.reason_highest_priority, Some(true));
|
||||
assert_eq!(message.category.as_deref(), Some("child_safety"));
|
||||
let user_flow = user.flow.as_ref().expect("the v2 user report has answers");
|
||||
assert_eq!(user.reason.as_deref(), Some("harassment"));
|
||||
assert_eq!(user_flow.locale.as_deref(), Some("de"));
|
||||
assert_eq!(user_flow.steps[0].screen_id, "profile_intro");
|
||||
assert!(user_flow.steps[0].option_id.is_none());
|
||||
assert!(user_flow.steps[0].items.is_empty());
|
||||
assert_eq!(
|
||||
user_flow.steps[1]
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["photo", "profile_text"]
|
||||
);
|
||||
assert!(legacy.reason.is_none());
|
||||
assert!(legacy.reason_label.is_none());
|
||||
assert!(legacy.flow.is_none());
|
||||
assert!(legacy.reporter_good_faith_confirmed.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_v2_report_fixture_with_reason_and_answers() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reason=csam",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(r#"name="reason""#), "{}", list.body);
|
||||
assert!(
|
||||
list.body.contains("Priority: Child sexual abuse material"),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
list.body.contains(r#"data-report-reason="csam""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
!list.body.contains(r#"data-report-reason="harassment""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115705480394",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
"Report Answers",
|
||||
"Report profile",
|
||||
"Which parts of their profile are a problem?: Pictures, Profile text",
|
||||
"Shown to the reporter in de",
|
||||
"Surface: In app",
|
||||
"Harassment or bullying",
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
|
||||
let legacy = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115709674699",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(legacy.status, 200, "{legacy:#?}");
|
||||
assert!(!legacy.body.contains("Report Answers"), "{}", legacy.body);
|
||||
assert!(
|
||||
!legacy.body.contains("data-report-reason"),
|
||||
"{}",
|
||||
legacy.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_webhook_fixtures_match_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the generated response contract");
|
||||
assert_eq!(generated.reports.len(), 2);
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the hand-written response type");
|
||||
let [webhook, bot] = response.reports.as_slice() else {
|
||||
panic!("expected two reports");
|
||||
};
|
||||
assert_eq!(webhook.report_id, WEBHOOK_REPORT_ID);
|
||||
assert!(webhook.reported_user_id.is_none());
|
||||
assert_eq!(webhook.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
webhook.reported_webhook_name.as_deref(),
|
||||
Some("Harbor Bulletin")
|
||||
);
|
||||
assert_eq!(bot.reported_user_id.as_deref(), Some(BOT_USER_ID));
|
||||
assert!(bot.reported_webhook_id.is_none());
|
||||
|
||||
let generated_detail: ReportAdminResponseSchema = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated_detail).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert!(detail.reported_user_id.is_none());
|
||||
assert_eq!(detail.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_id.as_deref(),
|
||||
Some(WEBHOOK_CREATOR_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_global_name.as_deref(),
|
||||
Some("Morgan Owner")
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_type, Some(1));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_channel_id,
|
||||
detail.reported_channel_id
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_guild_id, detail.reported_guild_id);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_created_at.as_deref(),
|
||||
Some("2026-10-04T01:23:15.892Z")
|
||||
);
|
||||
assert!(detail.reported_webhook_application_id.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let entry = context
|
||||
.iter()
|
||||
.find(|entry| entry["webhook_id"].as_str() == Some(WEBHOOK_ID))
|
||||
.expect("the webhook message is in the context");
|
||||
assert_eq!(entry["author_id"].as_str(), Some(WEBHOOK_ID));
|
||||
assert_eq!(entry["author_username"].as_str(), Some("Harbor Bulletin"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_webhook_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let webhook_marker = format!(r#"data-report-webhook="{WEBHOOK_ID}""#);
|
||||
let webhook_user_link = format!("/users/{WEBHOOK_ID}");
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports?reported_webhook_id={WEBHOOK_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(WEBHOOK_REPORT_ID), "{}", list.body);
|
||||
assert!(!list.body.contains(BOT_REPORT_ID), "{}", list.body);
|
||||
assert!(list.body.contains(&webhook_marker), "{}", list.body);
|
||||
assert!(list.body.contains("Harbor Bulletin"), "{}", list.body);
|
||||
assert!(list.body.contains("Channel: general"), "{}", list.body);
|
||||
assert!(!list.body.contains(&webhook_user_link), "{}", list.body);
|
||||
|
||||
let both = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reporter_id=1556114449113285826",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(both.status, 200, "{both:#?}");
|
||||
assert!(
|
||||
both.body
|
||||
.contains(&format!(r#"href="/users/{BOT_USER_ID}""#)),
|
||||
"{}",
|
||||
both.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{WEBHOOK_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
webhook_marker.clone(),
|
||||
">Webhook ID<".to_owned(),
|
||||
format!(r#"href="/reports?reported_webhook_id={WEBHOOK_ID}""#),
|
||||
"Harbor Commons".to_owned(),
|
||||
"Message Context".to_owned(),
|
||||
format!(r#"data-message-webhook="{WEBHOOK_ID}""#),
|
||||
format!(r#"href="/users/{BOT_USER_ID}""#),
|
||||
">Webhook Creator<".to_owned(),
|
||||
format!(r#"data-report-webhook-creator="{WEBHOOK_CREATOR_ID}""#),
|
||||
format!(r#"href="/users/{WEBHOOK_CREATOR_ID}""#),
|
||||
"Morgan Owner".to_owned(),
|
||||
">Webhook Type<".to_owned(),
|
||||
">Incoming<".to_owned(),
|
||||
">Webhook Created<".to_owned(),
|
||||
"Oct 4, 2026, 1:23 AM UTC".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert!(!detail.body.contains(&webhook_user_link), "{}", detail.body);
|
||||
assert!(
|
||||
!detail.body.contains("View Reported User"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
for absent in [
|
||||
">Webhook Channel ID<",
|
||||
">Webhook Guild ID<",
|
||||
">Webhook Record<",
|
||||
"data-report-webhook-creator-deleted",
|
||||
] {
|
||||
assert!(!detail.body.contains(absent), "{absent}\n{}", detail.body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_evidence_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: ReportAdminResponseSchema = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert_eq!(detail.reported_user_id.as_deref(), Some(EVIDENCE_BOT_ID));
|
||||
assert_eq!(detail.reported_user_bot, Some(true));
|
||||
let snapshot = detail
|
||||
.reported_profile_snapshot
|
||||
.as_ref()
|
||||
.expect("the bot author has a profile snapshot");
|
||||
let user = snapshot.user.as_ref().expect("user snapshot");
|
||||
assert_eq!(user.id, EVIDENCE_BOT_ID);
|
||||
assert_eq!(user.username.as_deref(), Some("Harbor_Helper"));
|
||||
let member = snapshot.member.as_ref().expect("member snapshot");
|
||||
assert_eq!(member.guild_id, "1556352159090475158");
|
||||
assert!(member.joined_at.is_some());
|
||||
assert!(snapshot.guild.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let author_bot = |author_id: &str| {
|
||||
context
|
||||
.iter()
|
||||
.filter(|entry| entry["author_id"].as_str() == Some(author_id))
|
||||
.map(|entry| entry["author_bot"].clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(author_bot(EVIDENCE_BOT_ID), [json!(true), json!(true)]);
|
||||
assert_eq!(author_bot(EVIDENCE_WEBHOOK_ID), [Value::Null]);
|
||||
assert_eq!(author_bot(EVIDENCE_DELETED_AUTHOR_ID), [Value::Null]);
|
||||
assert_eq!(author_bot("1556352159069503633"), [json!(false)]);
|
||||
assert!(context.iter().all(|entry| {
|
||||
entry["missing_attachments"]
|
||||
.as_array()
|
||||
.is_none_or(Vec::is_empty)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_the_evidence_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{EVIDENCE_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
format!(r#"data-report-user-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-author-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-webhook="{EVIDENCE_WEBHOOK_ID}""#),
|
||||
"At Report Time".to_owned(),
|
||||
"Harbor_Helper#3966".to_owned(),
|
||||
">Community Profile<".to_owned(),
|
||||
r#"data-report-legal-hold="none""#.to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
detail.body.matches("data-message-author-bot=").count(),
|
||||
1,
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
assert!(
|
||||
!detail.body.contains("data-missing-attachment"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_v2_report_api() -> Result<String, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind v2 report API: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read v2 report API address: {error}"))?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
get(|| async {
|
||||
fixture_json(include_str!("parity/fixtures/api/admin_user_me.json"))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/report-reasons",
|
||||
get(|| async {
|
||||
Json(json!({"reasons": [
|
||||
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
|
||||
{"key": "harassment", "label": "Harassment or bullying", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "harassment"}
|
||||
]}))
|
||||
.into_response()
|
||||
}),
|
||||
)
|
||||
.route("/admin/reports", get(v2_report_search))
|
||||
.route("/admin/reports/{report_id}", get(v2_report_detail));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
Ok(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
|
||||
fn fixture_json(body: &'static str) -> Response {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
fn v2_reports() -> Vec<Value> {
|
||||
[SEARCH_REPORTS_V2, SEARCH_REPORTS_WEBHOOK]
|
||||
.into_iter()
|
||||
.flat_map(|body| {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
value["reports"].as_array().cloned().unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn v2_report_search(RawQuery(query): RawQuery) -> Response {
|
||||
let query = query.unwrap_or_default();
|
||||
let filters = url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(key, value)| {
|
||||
matches!(
|
||||
key.as_ref(),
|
||||
"reason" | "reporter_id" | "reported_webhook_id"
|
||||
) && !value.is_empty()
|
||||
})
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let reports = v2_reports()
|
||||
.into_iter()
|
||||
.filter(|report| {
|
||||
filters
|
||||
.iter()
|
||||
.all(|(key, value)| report[key.as_str()].as_str() == Some(value.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"reports": reports, "total": reports.len(), "offset": 0, "limit": 25}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn v2_report_detail(Path(report_id): Path<String>) -> Response {
|
||||
if report_id == WEBHOOK_REPORT_ID {
|
||||
return fixture_json(REPORT_WEBHOOK_DETAIL);
|
||||
}
|
||||
if report_id == EVIDENCE_REPORT_ID {
|
||||
return fixture_json(REPORT_EVIDENCE_DETAIL);
|
||||
}
|
||||
match v2_reports()
|
||||
.into_iter()
|
||||
.find(|report| report["report_id"].as_str() == Some(report_id.as_str()))
|
||||
{
|
||||
Some(report) => Json(report).into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_error(message: String) -> Box<dyn Error> {
|
||||
Box::new(io::Error::other(message))
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderName, HeaderValue, Request, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::any,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ApiFixtureRoute {
|
||||
method: String,
|
||||
path: String,
|
||||
status: u16,
|
||||
content_type: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApiFixtureSet {
|
||||
routes: Arc<Vec<ApiFixtureRoute>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiFixtureManifest {
|
||||
routes: Vec<ApiFixtureManifestRoute>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiFixtureManifestRoute {
|
||||
method: String,
|
||||
path: String,
|
||||
status: Option<u16>,
|
||||
content_type: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<String>,
|
||||
body_file: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ApiFixtureServer {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ApiFixtureServer {
|
||||
pub async fn start_default() -> Result<Self, String> {
|
||||
let fixture_set = ApiFixtureSet::from_default_manifest()?;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind fixture API server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read fixture API address: {error}"))?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.fallback(any(handle_fixture_request))
|
||||
.with_state(fixture_set.routes);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
Ok(Self {
|
||||
base_url: format!("http://127.0.0.1:{port}"),
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ApiFixtureServer {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiFixtureSet {
|
||||
fn from_default_manifest() -> Result<Self, String> {
|
||||
let manifest: ApiFixtureManifest =
|
||||
serde_json::from_str(include_str!("fixtures/api_routes.json"))
|
||||
.map_err(|error| format!("failed to parse API fixture manifest: {error}"))?;
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/parity/fixtures/api");
|
||||
let mut routes = Vec::with_capacity(manifest.routes.len());
|
||||
for route in manifest.routes {
|
||||
routes.push(ApiFixtureRoute {
|
||||
method: route.method.to_ascii_uppercase(),
|
||||
path: route.path,
|
||||
status: route.status.unwrap_or(200),
|
||||
content_type: route
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/json; charset=utf-8".to_owned()),
|
||||
headers: route.headers.unwrap_or_default(),
|
||||
body: load_body(&root, route.body, route.body_file)?,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
routes: Arc::new(routes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_fixture_request(
|
||||
State(routes): State<Arc<Vec<ApiFixtureRoute>>>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let method = request.method().as_str();
|
||||
let path = request.uri().path();
|
||||
let fixture = routes
|
||||
.iter()
|
||||
.find(|route| route.method == method && route.path == path)
|
||||
.or_else(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| route.method == "ANY" && route.path == path)
|
||||
});
|
||||
match fixture {
|
||||
Some(fixture) => fixture_response(fixture),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
)],
|
||||
format!(r#"{{"error":"missing fixture","method":"{method}","path":"{path}"}}"#),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_response(fixture: &ApiFixtureRoute) -> Response {
|
||||
let status = StatusCode::from_u16(fixture.status).unwrap_or(StatusCode::OK);
|
||||
let mut response = (status, fixture.body.clone()).into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(&fixture.content_type) {
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, value);
|
||||
}
|
||||
for (name, value) in &fixture.headers {
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(name.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn load_body(
|
||||
root: &Path,
|
||||
body: Option<String>,
|
||||
body_file: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
if let Some(body) = body {
|
||||
return Ok(body);
|
||||
}
|
||||
let Some(body_file) = body_file else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let path = root.join(body_file);
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read fixture {}: {error}", path.display()))
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::html_normalizer;
|
||||
use reqwest::{Client, redirect::Policy};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NormalizedResponse {
|
||||
pub status: u16,
|
||||
pub content_type: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub fn capture_client() -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build capture client: {error}"))
|
||||
}
|
||||
|
||||
pub async fn fetch_route(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
route: &str,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<NormalizedResponse, String> {
|
||||
let url = format!("{}{}", base_url.trim_end_matches('/'), route);
|
||||
let mut request = client.get(&url);
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(reqwest::header::COOKIE, cookie);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("failed to fetch {url}: {error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let headers = response.headers().clone();
|
||||
let content_type = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| html_normalizer::normalize_header_value("content-type", value));
|
||||
let location = headers
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| html_normalizer::normalize_header_value("location", value));
|
||||
let raw_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("failed to read body for {url}: {error}"))?;
|
||||
let body = html_normalizer::normalize_body(content_type.as_deref(), &raw_body);
|
||||
Ok(NormalizedResponse {
|
||||
status,
|
||||
content_type,
|
||||
location,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"user": {
|
||||
"id": "1130650140672000000",
|
||||
"username": "parity_admin",
|
||||
"discriminator": 0,
|
||||
"global_name": "Parity Admin",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": "Parity fixture admin user.",
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": "2000-01-01",
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"has_totp": true,
|
||||
"authenticator_types": [1],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"guild": {
|
||||
"id": "1600000000000000001",
|
||||
"owner_id": "1508576042312688531",
|
||||
"owner_username": "parity_user",
|
||||
"owner_global_name": "Parity User",
|
||||
"owner_discriminator": "4363",
|
||||
"name": "Parity Guild",
|
||||
"vanity_url_code": "parity",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"splash": null,
|
||||
"embed_splash": null,
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"verification_level": 1,
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 2,
|
||||
"default_message_notifications": 1,
|
||||
"afk_channel_id": null,
|
||||
"afk_timeout": 300,
|
||||
"system_channel_id": null,
|
||||
"system_channel_flags": 0,
|
||||
"rules_channel_id": null,
|
||||
"disabled_operations": 0,
|
||||
"member_count": 128,
|
||||
"channels": [
|
||||
{
|
||||
"id": "1600000000000000101",
|
||||
"name": "general",
|
||||
"type": 0,
|
||||
"position": 0,
|
||||
"parent_id": null,
|
||||
"nsfw": false,
|
||||
"nsfw_override": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"url": null
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "1600000000000000001",
|
||||
"name": "@everyone",
|
||||
"color": 0,
|
||||
"position": 0,
|
||||
"permissions": "0",
|
||||
"hoist": false,
|
||||
"mentionable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "1508576042312688531",
|
||||
"username": "parity_user",
|
||||
"discriminator": 4363,
|
||||
"global_name": "Parity User",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"access_token": "parity-access-token",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"id": "1130650140672000000"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"report_id": "1700000000000000001",
|
||||
"reporter_id": "1508576042312688531",
|
||||
"reporter_tag": "parity_user#4363",
|
||||
"reporter_username": "parity_user",
|
||||
"reporter_global_name": "Parity User",
|
||||
"reporter_discriminator": "4363",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-05-26T15:10:45.134Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "nsfw_violation",
|
||||
"additional_info": "Parity fixture report.",
|
||||
"reported_user_id": "1508576042312688531",
|
||||
"reported_user_tag": "parity_user#4363",
|
||||
"reported_user_username": "parity_user",
|
||||
"reported_user_global_name": "Parity User",
|
||||
"reported_user_discriminator": "4363",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_guild_id": "1600000000000000001",
|
||||
"reported_guild_name": "Parity Guild",
|
||||
"reported_message_id": "1800000000000000001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": []
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
{
|
||||
"report_id": "1556352159220498613",
|
||||
"reporter_id": "1556352159077892243",
|
||||
"reporter_tag": "target_a91da2c28aae#2089",
|
||||
"reporter_username": "target_a91da2c28aae",
|
||||
"reporter_global_name": "Jordan Target",
|
||||
"reporter_discriminator": "2089",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T17:12:47.653Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556352159149195428",
|
||||
"reported_user_tag": "Harbor_Helper#3966",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "3966",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_user_bot": true,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556352159090475158",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556352159161778343",
|
||||
"reported_channel_id": "1556352159103058073",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159069503633",
|
||||
"author_username": "reporter_c03a2ee3db86",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2642",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159073697938",
|
||||
"author_username": "unverified_7d9456caf467",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "4822",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159132418208",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159132418208",
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159203721393",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159203721393",
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159216304308",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159069503633",
|
||||
"username": "reporter_c03a2ee3db86",
|
||||
"discriminator": "2642",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159073697938",
|
||||
"username": "unverified_7d9456caf467",
|
||||
"discriminator": "4822",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159132418208",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159132418208",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159203721393",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159203721393",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159216304308",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reported_profile_snapshot": {
|
||||
"captured_at": "2026-10-04T17:12:47.653Z",
|
||||
"user": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"member": {
|
||||
"guild_id": "1556352159090475158",
|
||||
"nick": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"joined_at": "2026-10-04T17:12:47.614Z",
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"guild": null
|
||||
},
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449113285826",
|
||||
"author_username": "reporter_010908032cb3",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "7915",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449117480131",
|
||||
"author_username": "unverified_b8bab8c7a8c5",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0943",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449176200401",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449176200401"
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449247503586",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449247503586"
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449260086501",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449113285826",
|
||||
"username": "reporter_010908032cb3",
|
||||
"discriminator": "7915",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449117480131",
|
||||
"username": "unverified_b8bab8c7a8c5",
|
||||
"discriminator": "0943",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449176200401",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449176200401",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449247503586",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449247503586",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449260086501",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"guilds": [
|
||||
{
|
||||
"id": "1600000000000000001",
|
||||
"name": "Parity Guild",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"owner_id": "1508576042312688531",
|
||||
"owner_username": "parity_user",
|
||||
"owner_global_name": "Parity User",
|
||||
"owner_discriminator": "4363",
|
||||
"member_count": 128,
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1700000000000000001",
|
||||
"reporter_id": "1508576042312688531",
|
||||
"reporter_tag": "parity_user#4363",
|
||||
"reporter_username": "parity_user",
|
||||
"reporter_global_name": "Parity User",
|
||||
"reporter_discriminator": "4363",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-05-26T15:10:45.134Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "nsfw_violation",
|
||||
"additional_info": "Parity fixture report.",
|
||||
"reported_user_id": "1508576042312688531",
|
||||
"reported_user_tag": "parity_user#4363",
|
||||
"reported_user_username": "parity_user",
|
||||
"reported_user_global_name": "Parity User",
|
||||
"reported_user_discriminator": "4363",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_guild_id": "1600000000000000001",
|
||||
"reported_guild_name": "Parity Guild",
|
||||
"reported_message_id": "1800000000000000001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": []
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556008115701286089",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.848Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "child_safety",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115697091784",
|
||||
"reported_channel_id": "1556008115692897479",
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "csam",
|
||||
"reason_label": "Child sexual abuse material",
|
||||
"reason_highest_priority": true,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "abuse",
|
||||
"screen_title": "What does it involve?",
|
||||
"option_id": "sexual",
|
||||
"option_label": "Sexual content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "sexual",
|
||||
"screen_title": "Tell us about the sexual content",
|
||||
"option_id": "minor_sexual",
|
||||
"option_label": "Sexualizing a minor",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "minor_sexual",
|
||||
"screen_title": "What did you see involving a minor?",
|
||||
"option_id": "csam",
|
||||
"option_label": "Real or AI-generated images or videos of child sexual abuse",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115705480394",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.852Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": null,
|
||||
"reported_channel_id": null,
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": null,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "22a1e877519c31ca",
|
||||
"surface": "in_app",
|
||||
"locale": "de",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "profile_intro",
|
||||
"screen_title": "Report profile",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_parts",
|
||||
"screen_title": "Which parts of their profile are a problem?",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": [
|
||||
{
|
||||
"id": "photo",
|
||||
"label": "Pictures"
|
||||
},
|
||||
{
|
||||
"id": "profile_text",
|
||||
"label": "Profile text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"screen_id": "root_user",
|
||||
"screen_title": "What's wrong with their profile?",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_abuse",
|
||||
"screen_title": "What's harmful about their profile?",
|
||||
"option_id": "harassment",
|
||||
"option_label": "Their profile harasses or targets me or someone else",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115709674699",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.856Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": "1556008115638371514",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115676120259",
|
||||
"reported_channel_id": "1556008115650954429",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": null,
|
||||
"reason_label": null,
|
||||
"reason_highest_priority": null,
|
||||
"flow": null,
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556114449268475111",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.375Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556114449192977621",
|
||||
"reported_user_tag": "Harbor_Helper#1238",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "1238",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449205560536",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"offset": 0,
|
||||
"limit": 50
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "1508576042312688531",
|
||||
"username": "parity_user",
|
||||
"discriminator": 4363,
|
||||
"global_name": "Parity User",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/@me",
|
||||
"body_file": "admin_user_me.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/users/@me",
|
||||
"body_file": "oauth_user_me.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/oauth2/token",
|
||||
"body_file": "oauth_token.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/oauth2/token/revoke",
|
||||
"body": "{}"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users",
|
||||
"body_file": "search_users.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/1508576042312688531",
|
||||
"body_file": "lookup_user.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds",
|
||||
"body_file": "search_guilds.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds/1600000000000000001",
|
||||
"body_file": "lookup_guild.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/reports",
|
||||
"body_file": "search_reports.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/reports/1700000000000000001",
|
||||
"body_file": "report_detail.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/api-keys",
|
||||
"body": "[]"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Attribute {
|
||||
name: String,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
pub fn normalize_body(content_type: Option<&str>, body: &str) -> String {
|
||||
let content_type = content_type.unwrap_or("").to_ascii_lowercase();
|
||||
if content_type.contains("css") {
|
||||
return normalize_css_body(body);
|
||||
}
|
||||
if content_type.contains("html") {
|
||||
return normalize_html(body);
|
||||
}
|
||||
if content_type.contains("json")
|
||||
&& let Ok(value) = serde_json::from_str::<Value>(body)
|
||||
{
|
||||
return serde_json::to_string(&value).unwrap_or_else(|_| normalize_text(body));
|
||||
}
|
||||
normalize_text(body)
|
||||
}
|
||||
|
||||
fn normalize_css_body(body: &str) -> String {
|
||||
if body.trim().is_empty() || body.contains("not available") {
|
||||
"__CSS_EMPTY__".to_owned()
|
||||
} else {
|
||||
"__CSS_OK__".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_html(input: &str) -> String {
|
||||
let input = normalize_text(input);
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find('<') {
|
||||
let start = cursor + relative_start;
|
||||
output.push_str(&input[cursor..start]);
|
||||
let Some(end) = find_tag_end(&input, start) else {
|
||||
output.push_str(&input[start..]);
|
||||
return finish_html(output);
|
||||
};
|
||||
let raw_tag = &input[start..=end];
|
||||
output.push_str(&normalize_tag(raw_tag));
|
||||
cursor = end + 1;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
finish_html(output)
|
||||
}
|
||||
|
||||
pub fn normalize_text(input: &str) -> String {
|
||||
let mut value = input.replace("\r\n", "\n").replace('\r', "\n");
|
||||
value = normalize_host_ports(&value);
|
||||
value = strip_static_asset_queries(&value);
|
||||
value = strip_htmx_script_tags(&value);
|
||||
value = strip_rust_runtime_script_blocks(&value);
|
||||
value = normalize_intentional_rust_improvements(&value);
|
||||
value = replace_query_parameter_values(&value, "state", "__OAUTH_STATE__");
|
||||
value = replace_query_parameter_values(&value, "_csrf", "__CSRF_TOKEN__");
|
||||
value = replace_script_csrf_values(&value);
|
||||
value = normalize_cookie_tokens(&value);
|
||||
value.trim().to_owned()
|
||||
}
|
||||
|
||||
pub fn normalize_header_value(name: &str, value: &str) -> String {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"content-type" => normalize_content_type(value),
|
||||
"location" => normalize_text(value),
|
||||
"cookie" | "set-cookie" => normalize_cookie_header(value),
|
||||
_ => normalize_text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_content_type(value: &str) -> String {
|
||||
value
|
||||
.split(';')
|
||||
.map(|part| part.trim().to_ascii_lowercase())
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
pub fn normalize_cookie_header(value: &str) -> String {
|
||||
normalize_cookie_tokens(value)
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn replace_script_csrf_values(input: &str) -> String {
|
||||
let pattern = "var csrf=\"";
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find(pattern) {
|
||||
let start = cursor + relative_start;
|
||||
let token_start = start + pattern.len();
|
||||
output.push_str(&input[cursor..token_start]);
|
||||
let Some(relative_end) = input[token_start..].find('"') else {
|
||||
cursor = token_start;
|
||||
break;
|
||||
};
|
||||
let token_end = token_start + relative_end;
|
||||
let token = &input[token_start..token_end];
|
||||
if token.len() == 64 && token.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
output.push_str("__CSRF_TOKEN__");
|
||||
} else {
|
||||
output.push_str(token);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn finish_html(output: String) -> String {
|
||||
let output = remove_empty_flash_container(&output);
|
||||
let output = decode_html_entities(&output);
|
||||
remove_between_tag_whitespace(&output).trim().to_owned()
|
||||
}
|
||||
|
||||
fn remove_empty_flash_container(input: &str) -> String {
|
||||
input
|
||||
.replace(
|
||||
r#"<div class="empty:hidden" id="flash-container"></div>"#,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
r#"<div id="flash-container" class="empty:hidden"></div>"#,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
fn find_tag_end(input: &str, start: usize) -> Option<usize> {
|
||||
let mut quote: Option<char> = None;
|
||||
for (offset, ch) in input[start + 1..].char_indices() {
|
||||
match (quote, ch) {
|
||||
(Some(active), current) if current == active => quote = None,
|
||||
(None, '"' | '\'') => quote = Some(ch),
|
||||
(None, '>') => return Some(start + 1 + offset),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_tag(raw: &str) -> String {
|
||||
if raw.starts_with("<!--") {
|
||||
return String::new();
|
||||
}
|
||||
if raw.len() < 3 {
|
||||
return raw.to_owned();
|
||||
}
|
||||
let mut inner = raw[1..raw.len() - 1].trim();
|
||||
let self_closing = inner.ends_with('/');
|
||||
if self_closing {
|
||||
inner = inner[..inner.len() - 1].trim_end();
|
||||
}
|
||||
if inner.eq_ignore_ascii_case("!doctype html") {
|
||||
return String::new();
|
||||
}
|
||||
if inner.starts_with('!') || inner.starts_with('?') {
|
||||
return format!("<{}>", collapse_ascii_whitespace(inner));
|
||||
}
|
||||
if let Some(rest) = inner.strip_prefix('/') {
|
||||
return format!("</{}>", rest.trim().to_ascii_lowercase());
|
||||
}
|
||||
let (tag_name, rest) = split_tag_name(inner);
|
||||
let tag_name = tag_name.to_ascii_lowercase();
|
||||
let mut attributes = parse_attributes(rest);
|
||||
normalize_csrf_input_value(&tag_name, &mut attributes);
|
||||
let tag_attributes = attributes.clone();
|
||||
attributes.retain(|attribute| {
|
||||
!is_ignored_rust_runtime_attribute(&tag_name, &tag_attributes, attribute)
|
||||
});
|
||||
for attribute in &mut attributes {
|
||||
if let Some(value) = attribute.value.take() {
|
||||
attribute.value = Some(normalize_text(&decode_html_entities(&value)));
|
||||
}
|
||||
attribute.name = attribute.name.to_ascii_lowercase();
|
||||
if attribute.name == "class"
|
||||
&& let Some(ref mut value) = attribute.value
|
||||
{
|
||||
let mut tokens: Vec<&str> = value.split_whitespace().collect();
|
||||
tokens.retain(|token| *token != "font-normal");
|
||||
tokens.sort();
|
||||
*value = tokens.join(" ");
|
||||
}
|
||||
if attribute.value.as_deref() == Some("") && is_boolean_attribute(&attribute.name) {
|
||||
attribute.value = None;
|
||||
}
|
||||
}
|
||||
attributes.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.value.cmp(&right.value))
|
||||
});
|
||||
let mut normalized = format!("<{tag_name}");
|
||||
for attribute in attributes {
|
||||
normalized.push(' ');
|
||||
normalized.push_str(&attribute.name);
|
||||
if let Some(value) = attribute.value {
|
||||
normalized.push_str("=\"");
|
||||
normalized.push_str(&escape_attr(&value));
|
||||
normalized.push('"');
|
||||
}
|
||||
}
|
||||
normalized.push('>');
|
||||
if self_closing && !is_void_element(&tag_name) {
|
||||
normalized.push_str("</");
|
||||
normalized.push_str(&tag_name);
|
||||
normalized.push('>');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn split_tag_name(inner: &str) -> (&str, &str) {
|
||||
let split_at = inner
|
||||
.find(|ch: char| ch.is_ascii_whitespace())
|
||||
.unwrap_or(inner.len());
|
||||
let name = &inner[..split_at];
|
||||
let rest = inner[split_at..].trim_start();
|
||||
(name, rest)
|
||||
}
|
||||
|
||||
fn parse_attributes(input: &str) -> Vec<Attribute> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut attributes = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
let name_start = cursor;
|
||||
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'='
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if name_start == cursor {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
let name = input[name_start..cursor].to_owned();
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() || bytes[cursor] != b'=' {
|
||||
attributes.push(Attribute { name, value: None });
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() {
|
||||
attributes.push(Attribute {
|
||||
name,
|
||||
value: Some(String::new()),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let value = if bytes[cursor] == b'"' || bytes[cursor] == b'\'' {
|
||||
let quote = bytes[cursor];
|
||||
cursor += 1;
|
||||
let value_start = cursor;
|
||||
while cursor < bytes.len() && bytes[cursor] != quote {
|
||||
cursor += 1;
|
||||
}
|
||||
let value = input[value_start..cursor].to_owned();
|
||||
if cursor < bytes.len() {
|
||||
cursor += 1;
|
||||
}
|
||||
value
|
||||
} else {
|
||||
let value_start = cursor;
|
||||
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
input[value_start..cursor].to_owned()
|
||||
};
|
||||
attributes.push(Attribute {
|
||||
name,
|
||||
value: Some(value),
|
||||
});
|
||||
}
|
||||
attributes
|
||||
}
|
||||
|
||||
fn normalize_csrf_input_value(tag_name: &str, attributes: &mut [Attribute]) {
|
||||
if tag_name != "input" {
|
||||
return;
|
||||
}
|
||||
let is_csrf = attributes.iter().any(|attribute| {
|
||||
attribute.name.eq_ignore_ascii_case("name") && attribute.value.as_deref() == Some("_csrf")
|
||||
});
|
||||
if !is_csrf {
|
||||
return;
|
||||
}
|
||||
for attribute in attributes {
|
||||
if attribute.name.eq_ignore_ascii_case("value") {
|
||||
attribute.value = Some("__CSRF_TOKEN__".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignored_rust_runtime_attribute(
|
||||
tag_name: &str,
|
||||
tag_attributes: &[Attribute],
|
||||
attribute: &Attribute,
|
||||
) -> bool {
|
||||
let name = attribute.name.to_ascii_lowercase();
|
||||
if name.starts_with("hx-") {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"popovertarget" | "popovertargetaction" | "popover"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let is_drawer_panel = tag_name == "aside"
|
||||
&& tag_attributes
|
||||
.iter()
|
||||
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-panel"));
|
||||
if is_drawer_panel && matches!(name.as_str(), "id" | "aria-hidden") {
|
||||
return true;
|
||||
}
|
||||
let is_drawer_body = tag_attributes
|
||||
.iter()
|
||||
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-body"));
|
||||
if is_drawer_body && name == "id" {
|
||||
return true;
|
||||
}
|
||||
if name != "id" {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
attribute.value.as_deref(),
|
||||
Some(
|
||||
"users-results"
|
||||
| "guilds-results"
|
||||
| "applications-results"
|
||||
| "reports-results"
|
||||
| "user-peek"
|
||||
| "guild-peek"
|
||||
| "report-peek"
|
||||
| "guild-tab-content"
|
||||
| "user-tab-content"
|
||||
| "jobs-results"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_boolean_attribute(name: &str) -> bool {
|
||||
if name.starts_with("data-") {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
name,
|
||||
"allowfullscreen"
|
||||
| "async"
|
||||
| "autofocus"
|
||||
| "autoplay"
|
||||
| "checked"
|
||||
| "controls"
|
||||
| "defer"
|
||||
| "disabled"
|
||||
| "hidden"
|
||||
| "loop"
|
||||
| "multiple"
|
||||
| "muted"
|
||||
| "open"
|
||||
| "readonly"
|
||||
| "required"
|
||||
| "selected"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_void_element(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"area"
|
||||
| "base"
|
||||
| "br"
|
||||
| "col"
|
||||
| "embed"
|
||||
| "hr"
|
||||
| "img"
|
||||
| "input"
|
||||
| "link"
|
||||
| "meta"
|
||||
| "param"
|
||||
| "source"
|
||||
| "track"
|
||||
| "wbr"
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn decode_html_entities(value: &str) -> String {
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(""", "\"")
|
||||
.replace("←", "\u{2190}")
|
||||
.replace("→", "\u{2192}")
|
||||
.replace("—", "\u{2014}")
|
||||
.replace("–", "\u{2013}")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
fn collapse_ascii_whitespace(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn remove_between_tag_whitespace(input: &str) -> String {
|
||||
let chars = input.chars().collect::<Vec<_>>();
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < chars.len() {
|
||||
output.push(chars[cursor]);
|
||||
if chars[cursor] == '>' {
|
||||
cursor += 1;
|
||||
let whitespace_start = cursor;
|
||||
while cursor < chars.len() && chars[cursor].is_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor < chars.len() && chars[cursor] == '<' {
|
||||
continue;
|
||||
}
|
||||
if cursor > whitespace_start {
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_host_ports(input: &str) -> String {
|
||||
let value = replace_host_port(input, "127.0.0.1");
|
||||
replace_host_port(&value, "localhost")
|
||||
}
|
||||
|
||||
fn replace_host_port(input: &str, host: &str) -> String {
|
||||
let needle = format!("{host}:");
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(&needle) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start]);
|
||||
output.push_str(host);
|
||||
output.push_str(":__PORT__");
|
||||
cursor = start + needle.len();
|
||||
while cursor < input.len() && input.as_bytes()[cursor].is_ascii_digit() {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_static_asset_queries(input: &str) -> String {
|
||||
let mut value = strip_query_after_path(input, "/static/app.css");
|
||||
value = strip_query_after_path(&value, "/static/htmx.min.js");
|
||||
value
|
||||
}
|
||||
|
||||
fn strip_htmx_script_tags(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find("<script") {
|
||||
let start = cursor + relative_start;
|
||||
let Some(tag_end) = find_tag_end(input, start) else {
|
||||
break;
|
||||
};
|
||||
let tag = &input[start..=tag_end];
|
||||
if tag.to_ascii_lowercase().contains("/static/htmx.min.js") {
|
||||
output.push_str(&input[cursor..start]);
|
||||
let after_tag = tag_end + 1;
|
||||
if input[after_tag..].starts_with("</script>") {
|
||||
cursor = after_tag + "</script>".len();
|
||||
} else {
|
||||
cursor = after_tag;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
output.push_str(&input[cursor..=tag_end]);
|
||||
cursor = tag_end + 1;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_rust_runtime_script_blocks(input: &str) -> String {
|
||||
let mut value =
|
||||
strip_script_blocks_containing(input, "document.body.addEventListener('showFlash'");
|
||||
value = strip_script_blocks_containing(&value, "__fluxerAdminHtmxScrollPreserver");
|
||||
value = strip_script_blocks_containing(&value, "window.__adminCopyToClipboard");
|
||||
value = strip_script_blocks_containing(&value, "window.__fluxerDrawerInit");
|
||||
value
|
||||
}
|
||||
|
||||
fn strip_script_blocks_containing(input: &str, needle: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find("<script") {
|
||||
let start = cursor + relative_start;
|
||||
let Some(tag_end) = find_tag_end(input, start) else {
|
||||
break;
|
||||
};
|
||||
let content_start = tag_end + 1;
|
||||
let Some(relative_end) = input[content_start..].find("</script>") else {
|
||||
break;
|
||||
};
|
||||
let end = content_start + relative_end + "</script>".len();
|
||||
let block = &input[start..end];
|
||||
if block.contains(needle) {
|
||||
output.push_str(&input[cursor..start]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
output.push_str(&input[cursor..end]);
|
||||
cursor = end;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_intentional_rust_improvements(input: &str) -> String {
|
||||
input.replace(
|
||||
"flex flex-col gap-8 items-center",
|
||||
"flex flex-col gap-8 items-stretch",
|
||||
)
|
||||
}
|
||||
|
||||
fn strip_query_after_path(input: &str, path: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(path) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start]);
|
||||
output.push_str(path);
|
||||
cursor = start + path.len();
|
||||
if input[cursor..].starts_with('?') {
|
||||
cursor += 1;
|
||||
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn replace_query_parameter_values(input: &str, name: &str, replacement: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < input.len() {
|
||||
let remaining = &input[cursor..];
|
||||
let query_needle = format!("?{name}=");
|
||||
let amp_needle = format!("&{name}=");
|
||||
let query_pos = remaining.find(&query_needle);
|
||||
let amp_pos = remaining.find(&_needle);
|
||||
let Some((relative, needle_len)) =
|
||||
closest_match(query_pos, query_needle.len(), amp_pos, amp_needle.len())
|
||||
else {
|
||||
output.push_str(remaining);
|
||||
break;
|
||||
};
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start + needle_len]);
|
||||
output.push_str(replacement);
|
||||
cursor = start + needle_len;
|
||||
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn closest_match(
|
||||
left: Option<usize>,
|
||||
left_len: usize,
|
||||
right: Option<usize>,
|
||||
right_len: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
match (left, right) {
|
||||
(Some(left), Some(right)) if left <= right => Some((left, left_len)),
|
||||
(Some(_left), Some(right)) => Some((right, right_len)),
|
||||
(Some(left), None) => Some((left, left_len)),
|
||||
(None, Some(right)) => Some((right, right_len)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cookie_tokens(input: &str) -> String {
|
||||
let mut value = replace_prefixed_value(input, "admin_session=", "__SESSION__");
|
||||
value = replace_prefixed_value(&value, "session=", "__SESSION__");
|
||||
value = replace_prefixed_value(&value, "csrf_token=", "__CSRF_COOKIE__");
|
||||
value = replace_prefixed_value(&value, "oauth_state=", "__OAUTH_STATE__");
|
||||
replace_prefixed_value(&value, "flash=", "__FLASH__")
|
||||
}
|
||||
|
||||
fn replace_prefixed_value(input: &str, prefix: &str, replacement: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(prefix) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start + prefix.len()]);
|
||||
output.push_str(replacement);
|
||||
cursor = start + prefix.len();
|
||||
while cursor < input.len() && !is_cookie_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn is_url_delimiter(byte: u8) -> bool {
|
||||
matches!(
|
||||
byte,
|
||||
b'&' | b'"' | b'\'' | b'<' | b'>' | b')' | b' ' | b'\n' | b'\t'
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cookie_delimiter(byte: u8) -> bool {
|
||||
matches!(byte, b';' | b'"' | b'\'' | b' ' | b'\n' | b'\t')
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod api_fixtures;
|
||||
pub mod capture;
|
||||
pub mod html_normalizer;
|
||||
pub mod rust_server;
|
||||
|
||||
pub const TEST_ADMIN_SECRET: &str = "test-admin-secret";
|
||||
pub const TEST_ADMIN_USER_ID: &str = "1130650140672000000";
|
||||
pub const TEST_ACCESS_TOKEN: &str = "parity-access-token";
|
||||
@@ -1,88 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::TEST_ADMIN_SECRET;
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::{net::TcpListener, task::JoinHandle, time::sleep};
|
||||
|
||||
pub struct RunningRustAdmin {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl RunningRustAdmin {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningRustAdmin {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(api_endpoint: &str) -> Result<RunningRustAdmin, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind Rust admin server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read Rust admin address: {error}"))?
|
||||
.port();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let config = admin_config(port, api_endpoint, &base_url);
|
||||
let router = build_router(config);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
wait_for_health(&base_url).await?;
|
||||
Ok(RunningRustAdmin { base_url, handle })
|
||||
}
|
||||
|
||||
fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
secret_key_base: TEST_ADMIN_SECRET.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: api_endpoint.to_owned(),
|
||||
media_endpoint: format!("{api_endpoint}/media"),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
reports_bucket_origin: "https://reports.example.test".to_owned(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
|
||||
oauth_client_id: "1234567890123456789".to_owned(),
|
||||
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
|
||||
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
|
||||
build_version: "parity".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build health client: {error}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let url = format!("{}/_health", base_url.trim_end_matches('/'));
|
||||
let mut last_error = String::new();
|
||||
while Instant::now() < deadline {
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => last_error = format!("health returned {}", response.status()),
|
||||
Err(error) => last_error = error.to_string(),
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for {url}: {last_error}"))
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -31,30 +31,6 @@ struct MockApi {
|
||||
requests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let (status, body) = post_form(
|
||||
&app,
|
||||
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
||||
&format!("_csrf={csrf_token}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(app.saw(&format!(
|
||||
"POST /admin/users/{TARGET_ID}/password-reset-link"
|
||||
)));
|
||||
assert!(body.contains("Copy this link now. It is shown only once."));
|
||||
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
|
||||
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
||||
assert!(body.contains("Copy Link"));
|
||||
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains(RESET_URL));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
@@ -133,16 +109,6 @@ async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance()
|
||||
assert!(!page.contains("Revoke Recovery Kit"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_hide_email_actions_on_the_account_tab() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Change Email"));
|
||||
assert!(!page.contains("Verify Email"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_reset_link_action_needs_its_acl() {
|
||||
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
|
||||
@@ -152,52 +118,6 @@ async fn the_reset_link_action_needs_its_acl() {
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_email_actions() {
|
||||
let app = setup(true, "email", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(page.contains("Change Email"));
|
||||
assert!(page.contains("Verify Email"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
|
||||
let notice = "Accounts have no email address, so email bans have no effect.";
|
||||
let username = setup(true, "username", vec!["*"]).await;
|
||||
let username_csrf = csrf_token(&username).await;
|
||||
let (status, body) = post_form(
|
||||
&username,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={username_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(body.contains(notice));
|
||||
|
||||
let email = setup(true, "email", vec!["*"]).await;
|
||||
let email_csrf = csrf_token(&email).await;
|
||||
let (_, body) = post_form(
|
||||
&email,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={email_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(!body.contains(notice));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
|
||||
let app = setup(false, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
assert!(!app.saw("GET /.well-known/fluxer"));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
@@ -423,7 +343,6 @@ fn user(id: &str, username: &str, acls: &[&str]) -> Value {
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
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 tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "username-tags-test-secret";
|
||||
const ADMIN_ID: &str = "1500000000000000000";
|
||||
const TARGET_ID: &str = "1500000000000000042";
|
||||
const DISCRIMINATOR_INPUT: &str = r#"name="discriminator""#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockApi {
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_show_humans_without_a_tag() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(page.contains(r#"<div class="truncate text-neutral-500 text-xs">lilith</div>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_random_tags_show_tags_and_allow_tag_changes() {
|
||||
let page =
|
||||
account_page_with(true, "email", false, user(TARGET_ID, "member", 1234, false)).await;
|
||||
assert!(page.contains("member#1234"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_no_tags_show_humans_without_a_tag() {
|
||||
let page = account_page_with(true, "email", true, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_never_show_tags_even_if_told_random() {
|
||||
let page =
|
||||
account_page_with(true, "username", false, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_keep_bot_tags() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "helper", 4363, true)).await;
|
||||
assert!(page.contains("helper#4363"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_zero_tag() {
|
||||
let page = account_page(true, "email", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains("lilith#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_keeps_the_zero_tag() {
|
||||
let page = account_page(false, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
async fn account_page(self_hosted: bool, account_identity: &'static str, target: Value) -> String {
|
||||
account_page_with(
|
||||
self_hosted,
|
||||
account_identity,
|
||||
account_identity == "username",
|
||||
target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn account_page_with(
|
||||
self_hosted: bool,
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
) -> String {
|
||||
let api_endpoint = spawn_mock_api(MockApi {
|
||||
account_identity,
|
||||
unique_usernames,
|
||||
target,
|
||||
})
|
||||
.await;
|
||||
let router = build_router(test_config(api_endpoint, self_hosted));
|
||||
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/users/{TARGET_ID}?tab=account"))
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(mock: MockApi) -> 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(mock))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
|
||||
let target_user = format!("/admin/users/{TARGET_ID}");
|
||||
let target_sessions = format!("{target_user}/sessions");
|
||||
let target_credentials = format!("{target_user}/webauthn-credentials");
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({
|
||||
"user": user(ADMIN_ID, "lilith", 0, false)
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, "/.well-known/fluxer") => Json(json!({
|
||||
"features": {
|
||||
"premium_enabled": false,
|
||||
"account_identity": mock.account_identity,
|
||||
"tag_style": if mock.unique_usernames { "none" } else { "random" }
|
||||
}
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, p) if p == target_user => {
|
||||
Json(json!({ "users": [mock.target] })).into_response()
|
||||
}
|
||||
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
|
||||
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(id: &str, username: &str, discriminator: u16, bot: bool) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"discriminator": discriminator,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": null,
|
||||
"email_verified": false,
|
||||
"email_bounced": false,
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-GB",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": bot,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> 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(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
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(),
|
||||
self_hosted,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -313,7 +313,6 @@ fn admin_user() -> Value {
|
||||
|
||||
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(),
|
||||
|
||||
-2
@@ -64,8 +64,6 @@ export abstract class ICacheService {
|
||||
|
||||
abstract extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
|
||||
|
||||
abstract getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null>;
|
||||
|
||||
abstract publish(channel: string, message: string): Promise<void>;
|
||||
|
||||
abstract sadd(key: string, member: string, ttlSeconds?: number): Promise<void>;
|
||||
|
||||
@@ -226,15 +226,6 @@ export class InMemoryProvider extends ICacheService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const entry = this.getValueEntry(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
entry.expiresAt = Date.now() + newTtlSeconds * 1000;
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async publish(_channel: string, _message: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,12 +192,6 @@ export class KVCacheProvider extends ICacheService {
|
||||
return await this.client.extendLock(lockKey, token, ttlSeconds);
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const value = await this.client.getex(key, newTtlSeconds);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
|
||||
@@ -37,14 +37,12 @@ interface CassandraBatchOptions {
|
||||
interface ICassandraClient {
|
||||
connect(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
execute<P extends CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
options?: CassandraExecuteOptions,
|
||||
): Promise<cassandra.types.ResultSet>;
|
||||
batch(queries: Array<PreparedQuery>, options?: CassandraBatchOptions): Promise<void>;
|
||||
getNativeClient(): cassandra.Client;
|
||||
setLogger(logger: Logger): void;
|
||||
}
|
||||
|
||||
interface DefaultClientState {
|
||||
@@ -131,10 +129,6 @@ class CassandraClient implements ICassandraClient {
|
||||
this.logger.info({}, 'Cassandra connection closed');
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
|
||||
public async execute<P extends CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
options: CassandraExecuteOptions = {},
|
||||
@@ -160,10 +154,6 @@ class CassandraClient implements ICassandraClient {
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public setLogger(logger: Logger): void {
|
||||
this.logger = logger;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCassandra(config: CassandraConfig): Promise<void> {
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EmailI18nService, type IEmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {TestEmailService} from '@pkgs/email/src/TestEmailService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const CONFIG: EmailConfig = {
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Fluxer',
|
||||
appBaseUrl: 'https://example.com',
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
appealsEmail: '[email protected]',
|
||||
safetyEmail: '[email protected]',
|
||||
supportEmail: '[email protected]',
|
||||
productName: 'Fluxer',
|
||||
};
|
||||
|
||||
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(config, new EmailI18nService(), provider);
|
||||
await expect(service.sendEmailChangeNew('[email protected]', 'testuser', '123456', 'en-US')).resolves.toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
return sent[0];
|
||||
}
|
||||
|
||||
describe('EmailService reply-to', () => {
|
||||
it('sets the configured reply-to address on every message', async () => {
|
||||
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
|
||||
expect(message.replyTo).toBe('[email protected]');
|
||||
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
|
||||
});
|
||||
|
||||
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
|
||||
const message = await sendWith({...CONFIG, replyTo});
|
||||
expect(message).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
|
||||
function createCapturingServiceFor(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
|
||||
}
|
||||
|
||||
function createCapturingService(): {service: EmailService; sent: Array<EmailMessage>} {
|
||||
return createCapturingServiceFor(CONFIG);
|
||||
}
|
||||
|
||||
describe('EmailService report notices', () => {
|
||||
it('sends a receipt with the report id and target kind', async () => {
|
||||
const {service, sent} = createCapturingService();
|
||||
await expect(service.sendReportReceivedEmail('[email protected]', '1234567890', 'guild', 'en-US')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].to).toBe('[email protected]');
|
||||
expect(sent[0].subject).toBe('We received your Fluxer report');
|
||||
expect(sent[0].text).toContain('report about a community on Fluxer.');
|
||||
expect(sent[0].text).toContain('Report ID: 1234567890');
|
||||
});
|
||||
|
||||
it('sends the receipt in the requested locale', async () => {
|
||||
const {service, sent} = createCapturingService();
|
||||
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'message', 'de');
|
||||
expect(sent[0].subject).toBe('Wir haben deine Fluxer-Meldung erhalten');
|
||||
expect(sent[0].text).toContain('Meldungs-ID: 1234567890');
|
||||
});
|
||||
|
||||
it('sends a DSA decision notice with the public comment', async () => {
|
||||
const {service, sent} = createCapturingService();
|
||||
await expect(
|
||||
service.sendDsaReportResolvedEmail('[email protected]', '1234567890', 'We removed the content.', 'en-US'),
|
||||
).resolves.toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].to).toBe('[email protected]');
|
||||
expect(sent[0].subject).toBe('We made a decision on your Fluxer report');
|
||||
expect(sent[0].text).toContain('(ID: 1234567890)');
|
||||
expect(sent[0].text).toContain('Response from the Safety Team:\nWe removed the content.');
|
||||
expect(sent[0].text).toContain('Email [email protected] from this email address');
|
||||
});
|
||||
|
||||
it('sends a generic DSA decision notice without a public comment', async () => {
|
||||
const {service, sent} = createCapturingService();
|
||||
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
|
||||
expect(sent[0].text).not.toContain('Response from the Safety Team');
|
||||
expect(sent[0].text).not.toContain('\n\n\n');
|
||||
expect(sent[0].text).toContain('Email [email protected] from this email address');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestEmailService report notices', () => {
|
||||
it('records the receipt and the DSA decision notice', async () => {
|
||||
const service = new TestEmailService();
|
||||
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'user', 'en-US');
|
||||
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
|
||||
expect(service.listSentEmails().map(({to, type, metadata}) => ({to, type, metadata}))).toEqual([
|
||||
{to: '[email protected]', type: 'report_received', metadata: {report_id: '1234567890', target_kind: 'user'}},
|
||||
{
|
||||
to: '[email protected]',
|
||||
type: 'dsa_report_resolved',
|
||||
metadata: {report_id: '1234567890', public_comment: ''},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailService enforcement notices', () => {
|
||||
function capture(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
|
||||
}
|
||||
|
||||
async function sendBoth(config: EmailConfig, reason: string | null = 'Spam'): Promise<Array<string>> {
|
||||
const {service, sent} = capture(config);
|
||||
const until = new Date('2026-10-01T23:30:00Z');
|
||||
await service.sendAccountTempBannedEmail('[email protected]', 'testuser', reason, 24, until, 'en-US');
|
||||
await service.sendAccountScheduledForDeletionEmail('[email protected]', 'testuser', reason, until, 'en-US');
|
||||
expect(sent).toHaveLength(2);
|
||||
return sent.map((message) => message.text);
|
||||
}
|
||||
|
||||
it('links the configured terms and guidelines', async () => {
|
||||
for (const text of await sendBoth(CONFIG)) {
|
||||
expect(text).toContain(
|
||||
'Please review:\n- Terms of Service: https://example.com/terms\n- Community Guidelines: https://example.com/guidelines\n\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out the review list when no policy page is configured', async () => {
|
||||
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: null})) {
|
||||
expect(text).not.toContain('Please review:');
|
||||
expect(text).not.toContain('https://');
|
||||
expect(text).not.toContain('\n\n\n');
|
||||
expect(text).toContain('[email protected]');
|
||||
}
|
||||
});
|
||||
|
||||
it('lists only the configured page', async () => {
|
||||
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: 'https://rules.example.org'})) {
|
||||
expect(text).toContain('Please review:\n- Community Guidelines: https://rules.example.org\n\n');
|
||||
expect(text).not.toContain('Terms of Service:');
|
||||
}
|
||||
for (const text of await sendBoth({...CONFIG, termsUrl: 'https://tos.example.org', guidelinesUrl: null})) {
|
||||
expect(text).toContain('Please review:\n- Terms of Service: https://tos.example.org\n\n');
|
||||
expect(text).not.toContain('Community Guidelines:');
|
||||
}
|
||||
});
|
||||
|
||||
it('states the reason as its own paragraph', async () => {
|
||||
const [suspended, deletion] = await sendBoth(CONFIG);
|
||||
expect(suspended).toContain(' UTC\n\nReason: Spam\n\nDuring this time,');
|
||||
expect(deletion).toContain(' UTC\n\nReason: Spam\n\nThis is a serious enforcement action.');
|
||||
for (const text of [suspended, deletion]) {
|
||||
expect(text).not.toContain('\n\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
it.each([null, '', ' '])('leaves no gap when the reason is %j', async (reason) => {
|
||||
const withReason = await sendBoth(CONFIG);
|
||||
const [suspended, deletion] = await sendBoth(CONFIG, reason);
|
||||
expect(suspended).toContain(' UTC\n\nDuring this time,');
|
||||
expect(deletion).toContain(' UTC\n\nThis is a serious enforcement action.');
|
||||
for (const [index, text] of [suspended, deletion].entries()) {
|
||||
expect(text).not.toContain('Reason:');
|
||||
expect(text).not.toContain('\n\n\n');
|
||||
expect(text.split('\n\n')).toHaveLength(withReason[index].split('\n\n').length - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailService contact addresses', () => {
|
||||
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
|
||||
const DATE = new Date('2026-10-01T23:30:00Z');
|
||||
|
||||
async function sendAll(config: EmailConfig, locale: string): Promise<Record<string, string>> {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(config, new EmailI18nService(), provider);
|
||||
const to = '[email protected]';
|
||||
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, DATE, locale);
|
||||
await service.sendAccountScheduledForDeletionEmail(to, 'testuser', 'Spam', DATE, locale);
|
||||
await service.sendScheduledDeletionNotification(to, 'testuser', DATE, 'Spam', locale);
|
||||
await service.sendDsaReportResolvedEmail(to, '1234567890', 'We removed the content.', locale);
|
||||
await service.sendReportResolvedEmail(to, 'testuser', '1234567890', 'We removed the content.', locale);
|
||||
await service.sendAccountDeletionCancelledEmail(to, 'testuser', locale);
|
||||
await service.sendAccountDeletionRequestedEmail(to, 'testuser', null, DATE, locale);
|
||||
await service.sendAccountDeletionInactivityEmail(to, 'testuser', null, DATE, locale);
|
||||
expect(sent).toHaveLength(8);
|
||||
const [tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity] = sent.map(
|
||||
(message) => message.text,
|
||||
);
|
||||
return {tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity};
|
||||
}
|
||||
|
||||
it('names the hosted mailboxes in en-US', async () => {
|
||||
const hosted = await sendAll(CONFIG, 'en-US');
|
||||
expect(hosted.tempBan).toContain(
|
||||
'Email [email protected] from this email address and clearly explain why you believe the decision was incorrect.',
|
||||
);
|
||||
expect(hosted.deletion).toContain(
|
||||
'you have 60 days to submit an appeal. Email [email protected] from this email address.\n',
|
||||
);
|
||||
expect(hosted.deletionNotice).toContain(
|
||||
'you can submit an appeal. Email [email protected] from this email address.\n',
|
||||
);
|
||||
expect(hosted.dsaResolved).toContain('Email [email protected] from this email address, include your report ID,');
|
||||
expect(hosted.resolved).toContain('please contact [email protected].\n');
|
||||
expect(hosted.cancelled).toContain('If you have any questions, contact [email protected].\n');
|
||||
expect(hosted.requested).toContain('contact [email protected] from this email address before that date.\n');
|
||||
expect(hosted.inactivity).toContain('contact [email protected] from this email address before that date.\n');
|
||||
});
|
||||
|
||||
it('points at the instance administrators in en-US when no mailbox is configured', async () => {
|
||||
const neutral = await sendAll(SELF_HOSTED, 'en-US');
|
||||
expect(neutral.tempBan).toContain(
|
||||
'you can submit an appeal. Contact the administrators of this instance and clearly explain why you believe the decision was incorrect.',
|
||||
);
|
||||
expect(neutral.deletion).toContain(
|
||||
'you have 60 days to submit an appeal. Contact the administrators of this instance.\n',
|
||||
);
|
||||
expect(neutral.deletionNotice).toContain(
|
||||
'you can submit an appeal. Contact the administrators of this instance.\n',
|
||||
);
|
||||
expect(neutral.dsaResolved).toContain(
|
||||
'Contact the administrators of this instance, include your report ID, and explain why you think the decision is wrong.',
|
||||
);
|
||||
expect(neutral.resolved).toContain(
|
||||
'If you have any questions or concerns about this outcome, please contact the administrators of this instance.\n',
|
||||
);
|
||||
expect(neutral.cancelled).toContain('If you have any questions, contact the administrators of this instance.\n');
|
||||
expect(neutral.requested).toContain(
|
||||
"Your account is locked until then. If you didn't request this, or you want to keep your account, contact the administrators of this instance before that date.\n",
|
||||
);
|
||||
expect(neutral.inactivity).toContain(
|
||||
'If you want to keep your account, contact the administrators of this instance before that date.\n',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['de', 'die Administratoren dieser Instanz', 'Sende eine E-Mail von dieser E-Mail-Adresse an [email protected]'],
|
||||
['ja', 'このインスタンスの管理者', 'このメールアドレスから[email protected]までメールを送信'],
|
||||
])('renders both variants in %s', async (locale, administrators, hostedAppeal) => {
|
||||
const hosted = await sendAll(CONFIG, locale);
|
||||
const neutral = await sendAll(SELF_HOSTED, locale);
|
||||
expect(hosted.tempBan).toContain(hostedAppeal);
|
||||
for (const [name, text] of Object.entries(hosted)) {
|
||||
expect(text, name).toMatch(/(appeals|safety)@fluxer\.com/);
|
||||
expect(text, name).not.toContain(administrators);
|
||||
}
|
||||
for (const [name, text] of Object.entries(neutral)) {
|
||||
expect(text, name).toContain(administrators);
|
||||
expect(text, name).not.toContain('@');
|
||||
expect(text, name).not.toMatch(/\bnull\b/);
|
||||
expect(text, name).not.toContain('{');
|
||||
expect(text, name).not.toContain('\n\n\n');
|
||||
expect(text.split('\n'), name).toHaveLength(hosted[name].split('\n').length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailService support contact', () => {
|
||||
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
|
||||
const DATE = new Date('2026-10-01T23:30:00Z');
|
||||
|
||||
async function sendSupportEmails(config: EmailConfig, locale: string): Promise<Array<string>> {
|
||||
const {service, sent} = createCapturingServiceFor(config);
|
||||
const to = '[email protected]';
|
||||
await service.sendGiftChargebackNotification(to, 'testuser', locale);
|
||||
await service.sendHarvestCompletedEmail(to, 'testuser', 'https://example.com/d', 10, 1024, DATE, locale);
|
||||
await service.sendInactivityWarningEmail(to, 'testuser', DATE, DATE, locale);
|
||||
expect(sent).toHaveLength(3);
|
||||
return sent.map((message) => message.text);
|
||||
}
|
||||
|
||||
it('keeps the support team sentences on the hosted instance', async () => {
|
||||
const [gift, harvest, inactivity] = await sendSupportEmails(CONFIG, 'en-US');
|
||||
expect(gift).toContain(
|
||||
'\n\nIf you think this is a mistake, please contact our support team and include any details you have about the gift code and when you redeemed it.\n\n',
|
||||
);
|
||||
expect(harvest).toContain(
|
||||
"\n\nIf you didn't request this export, please change your password immediately and contact our support team.\n\n",
|
||||
);
|
||||
expect(inactivity).toContain("\n\nIf you've used Fluxer recently, please contact our support team right away.\n\n");
|
||||
});
|
||||
|
||||
it('points at the instance administrators when the instance has no support mailbox', async () => {
|
||||
const [gift, harvest, inactivity] = await sendSupportEmails({...SELF_HOSTED, productName: 'Example Chat'}, 'en-US');
|
||||
expect(gift).toContain(
|
||||
'\n\nIf you think this is a mistake, please contact the administrators of this instance and include any details you have about the gift code and when you redeemed it.\n\n',
|
||||
);
|
||||
expect(harvest).toContain(
|
||||
"\n\nIf you didn't request this export, please change your password immediately and contact the administrators of this instance.\n\n",
|
||||
);
|
||||
expect(inactivity).toContain(
|
||||
"\n\nIf you've used Example Chat recently, please contact the administrators of this instance right away.\n\n",
|
||||
);
|
||||
for (const text of [gift, harvest, inactivity]) {
|
||||
expect(text).not.toMatch(/support/i);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['de', 'unser Support-Team', 'die Administratoren dieser Instanz'],
|
||||
['ja', 'サポートチーム', 'このインスタンスの管理者'],
|
||||
])('renders both variants in %s', async (locale, supportTeam, administrators) => {
|
||||
const hosted = await sendSupportEmails(CONFIG, locale);
|
||||
const neutral = await sendSupportEmails(SELF_HOSTED, locale);
|
||||
for (const [index, text] of hosted.entries()) {
|
||||
expect(text).toContain(supportTeam);
|
||||
expect(text).not.toContain(administrators);
|
||||
expect(neutral[index]).toContain(administrators);
|
||||
expect(neutral[index]).not.toContain(supportTeam);
|
||||
expect(neutral[index].split('\n')).toHaveLength(text.split('\n').length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailService product name', () => {
|
||||
async function sendPasswordReset(config: EmailConfig, locale: string): Promise<EmailMessage> {
|
||||
const {service, sent} = createCapturingServiceFor(config);
|
||||
await expect(service.sendPasswordResetEmail('[email protected]', 'testuser', 'token', locale)).resolves.toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
return sent[0];
|
||||
}
|
||||
|
||||
it('names Fluxer on the hosted instance', async () => {
|
||||
const message = await sendPasswordReset(CONFIG, 'en-US');
|
||||
expect(message.subject).toBe('Reset your Fluxer password');
|
||||
expect(message.text).toContain('Fluxer');
|
||||
});
|
||||
|
||||
it.each(['en-US', 'de', 'ja'])('names the configured instance in the %s subject and body', async (locale) => {
|
||||
const message = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, locale);
|
||||
expect(message.subject).toContain('Example Chat');
|
||||
expect(message.text).toContain('Example Chat');
|
||||
expect(`${message.subject}\n${message.text}`).not.toContain('Fluxer');
|
||||
});
|
||||
|
||||
it('uses the name of the config it was built with, so a renamed instance sends the new name', async () => {
|
||||
const before = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, 'en-US');
|
||||
const after = await sendPasswordReset({...CONFIG, productName: 'Renamed Chat'}, 'en-US');
|
||||
expect(before.subject).toBe('Reset your Example Chat password');
|
||||
expect(after.subject).toBe('Reset your Renamed Chat password');
|
||||
expect(after.text).not.toContain('Example Chat');
|
||||
});
|
||||
|
||||
it('passes the name to every template the service sends', async () => {
|
||||
const names: Array<string> = [];
|
||||
const i18n = new EmailI18nService();
|
||||
const recording: IEmailI18nService = {
|
||||
getTemplate: (key, locale, variables, productName) => {
|
||||
names.push(productName);
|
||||
return i18n.getTemplate(key, locale, variables, productName);
|
||||
},
|
||||
};
|
||||
const service = new EmailService({...CONFIG, productName: 'Example Chat'}, recording, {
|
||||
sendEmail: async () => true,
|
||||
});
|
||||
const date = new Date('2026-10-01T23:30:00Z');
|
||||
const to = '[email protected]';
|
||||
await service.sendEmailVerification(to, 'testuser', 'token', 'en-US');
|
||||
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, date, 'en-US');
|
||||
await service.sendReportResolvedEmail(to, 'testuser', '1', 'Thanks', 'en-US');
|
||||
await service.sendSelfDeletionScheduledEmail(to, 'testuser', date, 'en-US');
|
||||
expect(names).toEqual(['Example Chat', 'Example Chat', 'Example Chat', 'Example Chat']);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
|
||||
}));
|
||||
|
||||
const MESSAGE = {
|
||||
to: '[email protected]',
|
||||
from: {email: '[email protected]', name: 'Fluxer'},
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
};
|
||||
|
||||
function createProvider(): SmtpEmailProvider {
|
||||
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
|
||||
}
|
||||
|
||||
describe('SmtpEmailProvider', () => {
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
sendMail.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('passes the reply-to address to nodemailer', async () => {
|
||||
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
to: '[email protected]',
|
||||
from: 'Fluxer <[email protected]>',
|
||||
replyTo: '[email protected]',
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the reply-to address when the message has none', async () => {
|
||||
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
|
||||
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {extractMessageTemplatePlaceholders} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
|
||||
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import {EMAIL_I18N_LOCALE_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nLocales';
|
||||
import {EMAIL_I18N_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
|
||||
import type {EmailLegalLinks, EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -100,31 +94,6 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
|
||||
unban_notification: {username: 'testuser', reason: 'Appeal accepted'},
|
||||
};
|
||||
|
||||
const CONTACT_KEYS = [
|
||||
'account_deletion_cancelled',
|
||||
'account_deletion_scheduled_inactivity',
|
||||
'account_deletion_scheduled_requested',
|
||||
'account_scheduled_deletion',
|
||||
'account_temp_banned',
|
||||
'dsa_report_resolved',
|
||||
'report_resolved',
|
||||
'scheduled_deletion_notification',
|
||||
] as const satisfies ReadonlyArray<EmailTemplateKey>;
|
||||
|
||||
const SUPPORT_KEYS = [
|
||||
'gift_chargeback_notification',
|
||||
'harvest_completed',
|
||||
'inactivity_warning',
|
||||
] as const satisfies ReadonlyArray<EmailTemplateKey>;
|
||||
|
||||
function renderBody<K extends EmailTemplateKey>(key: K, locale: string, variables: EmailTemplateVariables[K]): string {
|
||||
const result = getEmailTemplate(key, locale, variables, 'Fluxer');
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.value.body;
|
||||
}
|
||||
|
||||
describe('EmailI18n locale files', () => {
|
||||
it.each(LOCALES)('%s loads without module errors', (locale) => {
|
||||
const template = getEmailTemplate(
|
||||
@@ -165,284 +134,4 @@ describe('EmailI18n locale files', () => {
|
||||
expect(result.value.body, key).not.toContain('Coordinated Universal Time');
|
||||
}
|
||||
});
|
||||
it('renders dates and times in UTC with a zone label', () => {
|
||||
expect(renderBody('self_deletion_scheduled', 'en-US', {username: 'testuser', deletionDate: DATE})).toContain(
|
||||
'Thursday, October 1, 2026 at 11:30 PM UTC',
|
||||
);
|
||||
});
|
||||
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
|
||||
'%s makes no enforcement claim',
|
||||
(key) => {
|
||||
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: 'Some reason'});
|
||||
expect(body).not.toContain('Terms of Service');
|
||||
expect(body.toLowerCase()).not.toContain('appeal');
|
||||
expect(body).toContain('Reason: Some reason');
|
||||
},
|
||||
);
|
||||
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
|
||||
'%s leaves no gap without a reason',
|
||||
(key) => {
|
||||
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: null});
|
||||
expect(body).not.toContain('Reason:');
|
||||
expect(body).not.toContain('\n\n\n');
|
||||
},
|
||||
);
|
||||
it.each(['en-US', ...LOCALES])('%s names each reported target kind in the receipt', (locale) => {
|
||||
const bodies = (['message', 'user', 'guild'] as const).map((targetKind) =>
|
||||
renderBody('report_received', locale, {reportId: '1234567890', targetKind}),
|
||||
);
|
||||
expect(new Set(bodies).size).toBe(3);
|
||||
for (const body of bodies) {
|
||||
expect(body).toContain('1234567890');
|
||||
expect(body).not.toContain('targetKind');
|
||||
}
|
||||
});
|
||||
it('renders the receipt for each target kind', () => {
|
||||
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'message'})).toContain(
|
||||
'report about a message on Fluxer.',
|
||||
);
|
||||
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'user'})).toContain(
|
||||
'report about an account on Fluxer.',
|
||||
);
|
||||
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'guild'})).toContain(
|
||||
'report about a community on Fluxer.',
|
||||
);
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s tells a DSA reporter how to challenge the decision', (locale) => {
|
||||
const withComment = renderBody('dsa_report_resolved', locale, {
|
||||
reportId: '1234567890',
|
||||
publicComment: 'We removed the content.',
|
||||
hasComment: 'yes',
|
||||
appeals_email: '[email protected]',
|
||||
});
|
||||
const withoutComment = renderBody('dsa_report_resolved', locale, {
|
||||
reportId: '1234567890',
|
||||
publicComment: '',
|
||||
hasComment: 'no',
|
||||
appeals_email: '[email protected]',
|
||||
});
|
||||
for (const body of [withComment, withoutComment]) {
|
||||
expect(body).toContain('1234567890');
|
||||
expect(body).toContain('[email protected]');
|
||||
expect(body).toContain('60');
|
||||
expect(body).not.toContain('\n\n\n');
|
||||
}
|
||||
expect(withComment).toContain('We removed the content.');
|
||||
expect(withComment.split('\n\n')).toHaveLength(withoutComment.split('\n\n').length + 1);
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s lists only the configured policy links in enforcement notices', (locale) => {
|
||||
const terms = 'https://example.com/terms';
|
||||
const guidelines = 'https://example.com/guidelines';
|
||||
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
|
||||
const render = (termsUrl: string | null, guidelinesUrl: string | null, legalLinks: EmailLegalLinks) =>
|
||||
renderBody(key, locale, {...FIXTURE[key], termsUrl, guidelinesUrl, legalLinks});
|
||||
const both = render(terms, guidelines, 'both');
|
||||
const termsOnly = render(terms, null, 'terms');
|
||||
const guidelinesOnly = render(null, guidelines, 'guidelines');
|
||||
const none = render(null, null, 'none');
|
||||
expect(both, key).toContain(terms);
|
||||
expect(both, key).toContain(guidelines);
|
||||
expect(termsOnly, key).toContain(terms);
|
||||
expect(termsOnly, key).not.toContain(guidelines);
|
||||
expect(guidelinesOnly, key).toContain(guidelines);
|
||||
expect(guidelinesOnly, key).not.toContain(terms);
|
||||
expect(none, key).not.toContain('https://');
|
||||
const bullets = (body: string) => body.split('\n').filter((line) => line.startsWith('- ')).length;
|
||||
expect(bullets(both) - bullets(none), key).toBe(2);
|
||||
expect(bullets(termsOnly) - bullets(none), key).toBe(1);
|
||||
expect(bullets(guidelinesOnly) - bullets(none), key).toBe(1);
|
||||
for (const body of [both, termsOnly, guidelinesOnly, none]) {
|
||||
expect(body, key).not.toContain('\n\n\n');
|
||||
expect(body, key).not.toContain('legalLinks');
|
||||
expect(body, key).toContain('[email protected]');
|
||||
}
|
||||
expect(termsOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
|
||||
expect(guidelinesOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
|
||||
expect(none.split('\n\n'), key).toHaveLength(both.split('\n\n').length - 1);
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s leaves no gap in enforcement notices without a reason', (locale) => {
|
||||
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
|
||||
for (const legalLinks of ['both', 'none'] as const) {
|
||||
const withReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: 'Repeated spam'});
|
||||
const withoutReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: null});
|
||||
expect(withReason, key).toContain('Repeated spam\n\n');
|
||||
expect(withoutReason, key).not.toContain('Repeated spam');
|
||||
for (const body of [withReason, withoutReason]) {
|
||||
expect(body, key).not.toContain('\n\n\n');
|
||||
expect(body, key).not.toContain('{');
|
||||
}
|
||||
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
|
||||
expect(withoutReason.split('\n'), key).toHaveLength(withReason.split('\n').length - 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s names no mailbox when the instance has none', (locale) => {
|
||||
const english = (key: (typeof CONTACT_KEYS)[number], variables: object) =>
|
||||
renderBody(key, 'en-US', {...FIXTURE[key], ...variables} as never);
|
||||
for (const key of CONTACT_KEYS) {
|
||||
const hosted = renderBody(key, locale, FIXTURE[key] as never);
|
||||
const neutral = renderBody(key, locale, {...FIXTURE[key], appeals_email: null, safety_email: null} as never);
|
||||
expect(hosted, key).toMatch(/(appeals|safety)@fluxer\.com/);
|
||||
expect(neutral, key).not.toContain('@');
|
||||
expect(neutral, key).not.toMatch(/\bnull\b/);
|
||||
expect(neutral, key).not.toContain('{');
|
||||
expect(neutral, key).not.toContain('\n\n\n');
|
||||
expect(neutral, key).not.toBe(hosted);
|
||||
expect(neutral.split('\n'), key).toHaveLength(hosted.split('\n').length);
|
||||
if (locale !== 'en-US' && locale !== 'en-GB') {
|
||||
const neutralLine = neutral.split('\n').find((line, index) => line !== hosted.split('\n')[index]);
|
||||
const englishNeutral = english(key, {appeals_email: null, safety_email: null}).split('\n');
|
||||
expect(neutralLine, key).toBeDefined();
|
||||
expect(englishNeutral, key).not.toContain(neutralLine);
|
||||
expect(neutralLine, key).not.toContain('the administrators of this instance');
|
||||
}
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s points at the instance administrators instead of a support team', (locale) => {
|
||||
for (const key of SUPPORT_KEYS) {
|
||||
const hosted = renderBody(key, locale, FIXTURE[key] as never);
|
||||
const neutral = renderBody(key, locale, {...FIXTURE[key], support_email: null} as never);
|
||||
const hostedLines = hosted.split('\n');
|
||||
const neutralLines = neutral.split('\n');
|
||||
expect(neutralLines, key).toHaveLength(hostedLines.length);
|
||||
const changed = neutralLines.filter((line, index) => line !== hostedLines[index]);
|
||||
expect(changed, key).toHaveLength(1);
|
||||
expect(neutral, key).not.toMatch(/\bnull\b/);
|
||||
expect(neutral, key).not.toContain('{');
|
||||
expect(neutral, key).not.toContain('@');
|
||||
expect(changed[0], key).not.toMatch(/support/i);
|
||||
if (locale !== 'en-US' && locale !== 'en-GB') {
|
||||
const englishNeutral = renderBody(key, 'en-US', {...FIXTURE[key], support_email: null} as never).split('\n');
|
||||
expect(englishNeutral, key).not.toContain(changed[0]);
|
||||
expect(changed[0], key).not.toContain('the administrators of this instance');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['en-US', ...LOCALES])('%s leaves no gap in deletion and unban notices without a reason', (locale) => {
|
||||
for (const key of ['scheduled_deletion_notification', 'unban_notification'] as const) {
|
||||
const withReason = renderBody(key, locale, {...FIXTURE[key], reason: 'Repeated spam'});
|
||||
const withoutReason = renderBody(key, locale, {...FIXTURE[key], reason: null});
|
||||
expect(withReason, key).toContain('Repeated spam\n\n');
|
||||
expect(withoutReason, key).not.toContain('Repeated spam');
|
||||
for (const body of [withReason, withoutReason]) {
|
||||
expect(body, key).not.toContain('\n\n\n');
|
||||
}
|
||||
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('greets a DSA reporter without a username', () => {
|
||||
for (const key of ['report_received', 'dsa_report_resolved'] as const) {
|
||||
const result = getEmailTemplate(key, 'en-US', FIXTURE[key], 'Fluxer');
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) continue;
|
||||
expect(result.value.body.startsWith('Hello,\n\n')).toBe(true);
|
||||
expect(result.value.body).not.toContain('{');
|
||||
}
|
||||
});
|
||||
it('renders a blank reason the same as no reason', async () => {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(
|
||||
{
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Fluxer',
|
||||
appBaseUrl: 'https://example.com',
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
appealsEmail: '[email protected]',
|
||||
safetyEmail: '[email protected]',
|
||||
supportEmail: '[email protected]',
|
||||
productName: 'Fluxer',
|
||||
},
|
||||
new EmailI18nService(),
|
||||
provider,
|
||||
);
|
||||
await service.sendUnbanNotification('[email protected]', 'testuser', ' ', 'en-US');
|
||||
await service.sendUnbanNotification('[email protected]', 'testuser', null, 'en-US');
|
||||
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', ' ', DATE, 'en-US');
|
||||
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', null, DATE, 'en-US');
|
||||
expect(sent).toHaveLength(4);
|
||||
expect(sent[0].text).toBe(sent[1].text);
|
||||
expect(sent[0].text).not.toContain('Reason:');
|
||||
expect(sent[2].text).toBe(sent[3].text);
|
||||
expect(sent[2].text).not.toContain('Reason:');
|
||||
});
|
||||
});
|
||||
|
||||
const HOSTED_RENDER_SHA256 = {
|
||||
email_verification: {
|
||||
'en-US': 'ea69bd530e1f9cf7cd04bc88d5c0a032069b2538bc5138eeee5bcea7dd21f539',
|
||||
de: 'beb941104326bf73a523de0ccd33b78d3b531f461eea6ce1fae37a88634e69fa',
|
||||
ja: 'e928c0d57baf6c1efd055d88661f6dc27f24119b0b972cafe614fed40663efb1',
|
||||
},
|
||||
password_reset: {
|
||||
'en-US': 'b37a3491ef9126f251fd01fe2aa642891eb50aa56c23c36d0d62ccd0a9a7785d',
|
||||
de: '148b9fbb52845befb1b9f24a063dafe2d5f000abc2104410d4e943ce56191917',
|
||||
ja: 'd4198d68c126ad796954010431fe966b4df2b2fbabb9b4ddd5da0fbfd3b645a5',
|
||||
},
|
||||
ip_authorization: {
|
||||
'en-US': '073ead7d3e270c886b44db16266f6cfb4a5b8b182d279a8bf17ddea49198b415',
|
||||
de: 'd1c38f6a791a87379ca5b67e270d66e3741db314933b0b68c10bb9aab1781de1',
|
||||
ja: 'adc16933f5efda8b176d7348942efaf6b2bff16bd09f14de9a48d727a76f011f',
|
||||
},
|
||||
gift_chargeback_notification: {
|
||||
'en-US': '4ec08598e9087578ba52aa2490a1198dbfe68dd3bd496f4175b3b8ce533a01ac',
|
||||
de: 'dd5a7f572a9063e26e5effa05193185286e28b5e6e256f3d0a98b97e7e31e952',
|
||||
ja: 'c8d3a0fce144d278303f3d36c919d17bd60ab0738578f7e0f3e15e79bdb2bf19',
|
||||
},
|
||||
unban_notification: {
|
||||
'en-US': '0cca736b44b8e1839bf3a1c9c8720c915ea80f40596801c57a9809c40c35c206',
|
||||
de: '926e3762019888815da2ce7c70e94661878b9fba417bf0ffb11836abb2b74904',
|
||||
ja: 'f5546b66f61d992aff2367c7b930d7683cd7a37891a3c2a069a8de2769113bd6',
|
||||
},
|
||||
} as const satisfies Partial<Record<EmailTemplateKey, Record<'en-US' | 'de' | 'ja', string>>>;
|
||||
|
||||
function renderEmail(key: EmailTemplateKey, locale: string, productName: string): {subject: string; body: string} {
|
||||
const result = getEmailTemplate(key, locale, FIXTURE[key], productName);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describe('EmailI18n product name', () => {
|
||||
it('renders the hosted name byte for byte as before the name became a parameter', () => {
|
||||
for (const [key, locales] of Object.entries(HOSTED_RENDER_SHA256)) {
|
||||
for (const [locale, expected] of Object.entries(locales)) {
|
||||
const {subject, body} = renderEmail(key as EmailTemplateKey, locale, 'Fluxer');
|
||||
const actual = createHash('sha256').update(`${subject}\n${body}`).digest('hex');
|
||||
expect(actual, `${key} ${locale}`).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s names the instance and never Fluxer in every template', (locale) => {
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const {subject, body} = renderEmail(key, locale, 'Example Chat');
|
||||
const rendered = `${subject}\n${body}`;
|
||||
expect(rendered, key).toContain('Example Chat');
|
||||
expect(rendered, key).not.toContain('Fluxer');
|
||||
expect(rendered, key).not.toContain('{product_name}');
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s differs from the hosted render only by the name', (locale) => {
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const hosted = renderEmail(key, locale, 'Fluxer');
|
||||
const selfHosted = renderEmail(key, locale, 'Example Chat');
|
||||
expect(selfHosted.subject.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.subject);
|
||||
expect(selfHosted.body.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.body);
|
||||
}
|
||||
});
|
||||
it('prints a name with message syntax characters as written', () => {
|
||||
const {subject, body} = renderEmail('password_reset', 'en-US', "Bob's {Chat} #1");
|
||||
expect(`${subject}\n${body}`).toContain("Bob's {Chat} #1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {createTestServer, readRequestBody, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
|
||||
import {createTestServer, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
|
||||
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
|
||||
import type {HttpClientMetrics, HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
|
||||
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
@@ -21,329 +17,8 @@ describe('HttpClient', () => {
|
||||
await testServer.close();
|
||||
await redirectServer.close();
|
||||
});
|
||||
describe('createHttpClient', () => {
|
||||
it('creates an HTTP client with the provided user agent', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end(req.headers['user-agent'] ?? '');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(body).toBe(TEST_USER_AGENT);
|
||||
});
|
||||
});
|
||||
describe('sendRequest', () => {
|
||||
describe('basic requests', () => {
|
||||
it('sends a GET request by default', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('GET');
|
||||
});
|
||||
it('sends a POST request with body', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const requestBody = {message: 'Hello, World!'};
|
||||
testServer.setHandler(async (req, res) => {
|
||||
const body = await readRequestBody(req);
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method, receivedBody: JSON.parse(body)}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('POST');
|
||||
expect(json.receivedBody).toEqual(requestBody);
|
||||
});
|
||||
it('sends URLSearchParams bodies as form-encoded payloads', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const requestBody = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'test-code',
|
||||
});
|
||||
testServer.setHandler(async (req, res) => {
|
||||
const body = await readRequestBody(req);
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
method: req.method,
|
||||
receivedBody: body,
|
||||
contentType: req.headers['content-type'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('POST');
|
||||
expect(json.receivedBody).toBe(requestBody.toString());
|
||||
expect(json.contentType).toContain('application/x-www-form-urlencoded');
|
||||
});
|
||||
it('sends a HEAD request', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain', 'X-Custom-Header': 'test-value'});
|
||||
res.end();
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'HEAD',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('X-Custom-Header')).toBe('test-value');
|
||||
});
|
||||
});
|
||||
describe('headers', () => {
|
||||
it('sends default headers including user agent', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
userAgent: req.headers['user-agent'],
|
||||
accept: req.headers['accept'],
|
||||
cacheControl: req.headers['cache-control'],
|
||||
pragma: req.headers['pragma'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(json.userAgent).toBe(TEST_USER_AGENT);
|
||||
expect(json.accept).toBe('*/*');
|
||||
expect(json.cacheControl).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(json.pragma).toBe('no-cache');
|
||||
});
|
||||
it('allows custom headers to override defaults', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
accept: req.headers['accept'],
|
||||
customHeader: req.headers['x-custom-header'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(json.accept).toBe('application/json');
|
||||
expect(json.customHeader).toBe('custom-value');
|
||||
});
|
||||
it('normalizes response headers correctly', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-Single-Value': 'single',
|
||||
});
|
||||
res.end('OK');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.headers.get('content-type')).toBe('text/plain');
|
||||
expect(response.headers.get('x-single-value')).toBe('single');
|
||||
});
|
||||
});
|
||||
describe('status codes', () => {
|
||||
it('returns 200 status for successful requests', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
it('returns 404 status for not found', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
it('returns 500 status for server errors', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
it('handles 304 Not Modified without following redirects', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(HttpStatus.NOT_MODIFIED);
|
||||
res.end();
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.status).toBe(HttpStatus.NOT_MODIFIED);
|
||||
expect(response.url).toBe(new URL(testServer.url).href);
|
||||
});
|
||||
});
|
||||
describe('redirects', () => {
|
||||
it('follows 301 redirect', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(301, {Location: `${redirectServer.url}/target`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({path: req.url, method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.url).toBe(`${redirectServer.url}/target`);
|
||||
expect(json.path).toBe('/target');
|
||||
});
|
||||
it('follows 301 redirect and changes POST to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {value: 'test'},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('GET');
|
||||
});
|
||||
it('follows 302 redirect', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(302, {Location: `${redirectServer.url}/found`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end(req.url ?? '');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toBe('/found');
|
||||
});
|
||||
it('follows 302 redirect preserving PATCH', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(302, {Location: `${redirectServer.url}/found`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'PATCH',
|
||||
body: {value: 'test'},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('PATCH');
|
||||
});
|
||||
it('follows 303 redirect and changes method to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {data: 'test'},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('GET');
|
||||
});
|
||||
it('drops content headers when redirect switches to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
let contentType: string | undefined;
|
||||
let contentLength: string | undefined;
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
contentType = req.headers['content-type'] as string | undefined;
|
||||
contentLength = req.headers['content-length'] as string | undefined;
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end('OK');
|
||||
});
|
||||
await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {data: 'test'},
|
||||
});
|
||||
expect(contentType).toBeUndefined();
|
||||
expect(contentLength).toBeUndefined();
|
||||
});
|
||||
it('drops content headers when 301 redirect switches to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
let contentType: string | undefined;
|
||||
let contentLength: string | undefined;
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
contentType = req.headers['content-type'] as string | undefined;
|
||||
contentLength = req.headers['content-length'] as string | undefined;
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end('OK');
|
||||
});
|
||||
await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {data: 'test'},
|
||||
});
|
||||
expect(contentType).toBeUndefined();
|
||||
expect(contentLength).toBeUndefined();
|
||||
});
|
||||
it('strips sensitive headers on cross-origin redirects', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const secretToken = 'Bearer ultra-secret-token';
|
||||
@@ -399,65 +74,6 @@ describe('HttpClient', () => {
|
||||
});
|
||||
expect(receivedAuthorization).toBe(secretToken);
|
||||
});
|
||||
it('follows 307 redirect preserving method', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(307, {Location: `${redirectServer.url}/temp`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {data: 'test'},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('POST');
|
||||
});
|
||||
it('follows 308 redirect preserving method', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(308, {Location: `${redirectServer.url}/permanent`});
|
||||
res.end();
|
||||
});
|
||||
redirectServer.setHandler((req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({method: req.method}));
|
||||
});
|
||||
const response = await client.sendRequest({
|
||||
url: testServer.url,
|
||||
method: 'POST',
|
||||
body: {data: 'test'},
|
||||
});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('POST');
|
||||
});
|
||||
it('follows multiple redirects up to max limit', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
let redirectCount = 0;
|
||||
testServer.setHandler((_req, res) => {
|
||||
redirectCount++;
|
||||
if (redirectCount < 5) {
|
||||
res.writeHead(302, {Location: `${testServer.url}/redirect${redirectCount}`});
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({redirectCount}));
|
||||
}
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.redirectCount).toBe(5);
|
||||
});
|
||||
it('throws error when exceeding max redirects', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
@@ -468,35 +84,6 @@ describe('HttpClient', () => {
|
||||
'Maximum number of redirects (5) exceeded',
|
||||
);
|
||||
});
|
||||
it('throws error when redirect has no Location header', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(302);
|
||||
res.end();
|
||||
});
|
||||
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
|
||||
'Received redirect response without Location header',
|
||||
);
|
||||
});
|
||||
it('handles relative redirect URLs', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
let requestCount = 0;
|
||||
testServer.setHandler((req, res) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
res.writeHead(302, {Location: '/relative-path'});
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify({path: req.url}));
|
||||
}
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.path).toBe('/relative-path');
|
||||
});
|
||||
it('validates redirect targets with request URL policy before following', async () => {
|
||||
const validationCalls: Array<RequestUrlValidationContext> = [];
|
||||
const requestUrlPolicy: RequestUrlPolicy = {
|
||||
@@ -526,15 +113,6 @@ describe('HttpClient', () => {
|
||||
});
|
||||
});
|
||||
describe('timeout', () => {
|
||||
it('uses default timeout of 30 seconds', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
it('respects custom timeout', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
@@ -551,248 +129,5 @@ describe('HttpClient', () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe('abort signal', () => {
|
||||
it('aborts request when signal is triggered', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const controller = new AbortController();
|
||||
testServer.setHandler((_req, res) => {
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
}, 1000);
|
||||
});
|
||||
const requestPromise = client.sendRequest({
|
||||
url: testServer.url,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
await expect(requestPromise).rejects.toThrow();
|
||||
});
|
||||
it('handles pre-aborted signal', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const controller = new AbortController();
|
||||
controller.abort('Pre-aborted');
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
});
|
||||
await expect(
|
||||
client.sendRequest({
|
||||
url: testServer.url,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
it('throws HttpError for connection refused', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
try {
|
||||
await client.sendRequest({url: 'http://127.0.0.1:1'});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
const httpError = error as HttpError;
|
||||
expect(httpError.isExpected).toBe(true);
|
||||
}
|
||||
});
|
||||
it('throws HttpError for DNS resolution failure', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
try {
|
||||
await client.sendRequest({url: 'http://this-domain-does-not-exist-12345.invalid'});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
const httpError = error as HttpError;
|
||||
expect(httpError.isExpected).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('streamToString', () => {
|
||||
it('converts response stream to string', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end('Hello, World!');
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(body).toBe('Hello, World!');
|
||||
});
|
||||
it('handles empty response body', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(body).toBe('');
|
||||
});
|
||||
it('handles large response body', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const largeContent = 'x'.repeat(1024 * 1024);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end(largeContent);
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(body.length).toBe(largeContent.length);
|
||||
});
|
||||
it('handles UTF-8 content correctly', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
const unicodeContent = 'Hello, World! Emoji: \u{1F600} Chinese: \u4E2D\u6587 Arabic: \u0639\u0631\u0628\u064A';
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
|
||||
res.end(unicodeContent);
|
||||
});
|
||||
const response = await client.sendRequest({url: testServer.url});
|
||||
const body = await client.streamToString(response.stream);
|
||||
expect(body).toBe(unicodeContent);
|
||||
});
|
||||
});
|
||||
describe('telemetry', () => {
|
||||
it('records metrics for successful requests', async () => {
|
||||
const recordedMetrics: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
}> = [];
|
||||
const metrics: HttpClientMetrics = {
|
||||
counter: (params) => {
|
||||
recordedMetrics.push({type: 'counter', ...params});
|
||||
},
|
||||
histogram: (params) => {
|
||||
recordedMetrics.push({type: 'histogram', ...params});
|
||||
},
|
||||
};
|
||||
const telemetry: HttpClientTelemetry = {metrics};
|
||||
const client = createHttpClient(TEST_USER_AGENT, telemetry);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
});
|
||||
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
|
||||
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
|
||||
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
|
||||
const responseMetric = recordedMetrics.find((m) => m.name === 'http_client.response');
|
||||
expect(latencyMetric).toBeDefined();
|
||||
expect(latencyMetric?.dimensions?.service).toBe('test-service');
|
||||
expect(latencyMetric?.dimensions?.method).toBe('GET');
|
||||
expect(requestMetric).toBeDefined();
|
||||
expect(requestMetric?.dimensions?.service).toBe('test-service');
|
||||
expect(requestMetric?.dimensions?.status).toBe('2xx');
|
||||
expect(responseMetric).toBeDefined();
|
||||
expect(responseMetric?.dimensions?.service).toBe('test-service');
|
||||
expect(responseMetric?.dimensions?.status_code).toBe('2xx');
|
||||
});
|
||||
it('records metrics for error responses', async () => {
|
||||
const recordedMetrics: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
}> = [];
|
||||
const metrics: HttpClientMetrics = {
|
||||
counter: (params) => {
|
||||
recordedMetrics.push({type: 'counter', ...params});
|
||||
},
|
||||
histogram: (params) => {
|
||||
recordedMetrics.push({type: 'histogram', ...params});
|
||||
},
|
||||
};
|
||||
const telemetry: HttpClientTelemetry = {metrics};
|
||||
const client = createHttpClient(TEST_USER_AGENT, telemetry);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
|
||||
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
|
||||
expect(requestMetric?.dimensions?.status).toBe('404');
|
||||
});
|
||||
it('records metrics for network errors', async () => {
|
||||
const recordedMetrics: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
}> = [];
|
||||
const metrics: HttpClientMetrics = {
|
||||
counter: (params) => {
|
||||
recordedMetrics.push({type: 'counter', ...params});
|
||||
},
|
||||
histogram: (params) => {
|
||||
recordedMetrics.push({type: 'histogram', ...params});
|
||||
},
|
||||
};
|
||||
const telemetry: HttpClientTelemetry = {metrics};
|
||||
const client = createHttpClient(TEST_USER_AGENT, telemetry);
|
||||
try {
|
||||
await client.sendRequest({url: 'http://127.0.0.1:1', serviceName: 'test-service'});
|
||||
} catch {}
|
||||
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
|
||||
expect(requestMetric?.dimensions?.status).toBe('network_error');
|
||||
});
|
||||
it('uses default service name when not provided', async () => {
|
||||
const recordedMetrics: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
}> = [];
|
||||
const metrics: HttpClientMetrics = {
|
||||
counter: (params) => {
|
||||
recordedMetrics.push({type: 'counter', ...params});
|
||||
},
|
||||
histogram: (params) => {
|
||||
recordedMetrics.push({type: 'histogram', ...params});
|
||||
},
|
||||
};
|
||||
const telemetry: HttpClientTelemetry = {metrics};
|
||||
const client = createHttpClient(TEST_USER_AGENT, telemetry);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
});
|
||||
await client.sendRequest({url: testServer.url});
|
||||
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
|
||||
expect(latencyMetric?.dimensions?.service).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpError', () => {
|
||||
it('creates error with message', () => {
|
||||
const error = new HttpError('Test error');
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.name).toBe('HttpError');
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.response).toBeUndefined();
|
||||
expect(error.isExpected).toBe(false);
|
||||
expect(error.errorType).toBeUndefined();
|
||||
});
|
||||
it('creates error with status code', () => {
|
||||
const error = new HttpError('Not Found', 404);
|
||||
expect(error.message).toBe('Not Found');
|
||||
expect(error.status).toBe(404);
|
||||
});
|
||||
it('creates error with response', () => {
|
||||
const response = new Response('Error body', {status: 500});
|
||||
const error = new HttpError('Server Error', 500, response);
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.response).toBe(response);
|
||||
});
|
||||
it('creates error with isExpected flag', () => {
|
||||
const error = new HttpError('Expected error', 400, undefined, true);
|
||||
expect(error.isExpected).toBe(true);
|
||||
});
|
||||
it('creates error with errorType', () => {
|
||||
const error = new HttpError('Aborted', undefined, undefined, false, 'aborted');
|
||||
expect(error.errorType).toBe('aborted');
|
||||
});
|
||||
it('is an instance of Error', () => {
|
||||
const error = new HttpError('Test');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,12 +54,3 @@ export async function createTestServer(): Promise<TestServer> {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface IKVSubscription {
|
||||
unsubscribe(...channels: Array<string>): Promise<void>;
|
||||
quit(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
removeAllListeners(event?: 'message' | 'error'): void;
|
||||
}
|
||||
|
||||
export interface KVPurgeBatchResult {
|
||||
@@ -78,22 +77,13 @@ export interface IKVProvider {
|
||||
ltrim(key: string, start: number, stop: number): Promise<void>;
|
||||
hset(key: string, field: string, value: string): Promise<number>;
|
||||
hdel(key: string, ...fields: Array<string>): Promise<number>;
|
||||
hget(key: string, field: string): Promise<string | null>;
|
||||
hgetall(key: string): Promise<Record<string, string>>;
|
||||
publish(channel: string, message: string): Promise<number>;
|
||||
duplicate(): IKVSubscription;
|
||||
acquireLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
|
||||
releaseLock(key: string, token: string): Promise<boolean>;
|
||||
extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
|
||||
renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean>;
|
||||
checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult>;
|
||||
tryConsumeTokens(
|
||||
key: string,
|
||||
requested: number,
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<number>;
|
||||
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
|
||||
claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean>;
|
||||
removeBulkDeletion(queueKey: string, secondaryKey: string, member?: string): Promise<boolean>;
|
||||
@@ -109,5 +99,4 @@ export interface IKVProvider {
|
||||
pipeline(): IKVPipeline;
|
||||
multi(): IKVPipeline;
|
||||
isClustered(): boolean;
|
||||
health(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -65,52 +65,6 @@ if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
const RENEW_SNOWFLAKE_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
const TRY_CONSUME_TOKENS_SCRIPT = `
|
||||
${DECODE_BUCKET_STATE_SCRIPT}
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local requested = tonumber(ARGV[2])
|
||||
local maxTokens = tonumber(ARGV[3])
|
||||
local refillRate = tonumber(ARGV[4])
|
||||
local refillIntervalMs = tonumber(ARGV[5])
|
||||
|
||||
local data = redis.call('GET', key)
|
||||
local tokens = maxTokens
|
||||
local lastRefill = now
|
||||
|
||||
if data then
|
||||
tokens, lastRefill = decodeBucketState(data, 'tokens', 'lastRefill')
|
||||
end
|
||||
|
||||
local elapsed = now - lastRefill
|
||||
if elapsed >= refillIntervalMs then
|
||||
local intervals = math.floor(elapsed / refillIntervalMs)
|
||||
local tokensToAdd = intervals * refillRate
|
||||
if tokensToAdd > 0 then
|
||||
tokens = math.min(maxTokens, tokens + tokensToAdd)
|
||||
lastRefill = now
|
||||
end
|
||||
end
|
||||
|
||||
local consumed = 0
|
||||
if tokens >= requested then
|
||||
consumed = requested
|
||||
tokens = tokens - requested
|
||||
elseif tokens > 0 then
|
||||
consumed = tokens
|
||||
tokens = 0
|
||||
end
|
||||
|
||||
redis.call('SET', key, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return consumed
|
||||
`;
|
||||
const CHECK_LEAKY_BUCKET_LIMIT_SCRIPT = `
|
||||
${DECODE_BUCKET_STATE_SCRIPT}
|
||||
local key = KEYS[1]
|
||||
@@ -324,15 +278,6 @@ export class KVClient implements IKVProvider {
|
||||
this.client.disconnect(false);
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.execute('health', async () => this.client.ping())) === 'PONG';
|
||||
} catch (error) {
|
||||
this.logger.debug({error}, 'KV health check failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return await this.execute('get', async () => this.client.get(key));
|
||||
}
|
||||
@@ -571,10 +516,6 @@ export class KVClient implements IKVProvider {
|
||||
return await this.execute('hdel', async () => this.client.hdel(key, ...fields));
|
||||
}
|
||||
|
||||
async hget(key: string, field: string): Promise<string | null> {
|
||||
return await this.execute('hget', async () => this.client.hget(key, field));
|
||||
}
|
||||
|
||||
async hgetall(key: string): Promise<Record<string, string>> {
|
||||
return await this.execute('hgetall', async () => this.client.hgetall(key));
|
||||
}
|
||||
@@ -611,18 +552,6 @@ export class KVClient implements IKVProvider {
|
||||
return parseIntegerDecision(result, 'extendLock');
|
||||
}
|
||||
|
||||
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
|
||||
const result = await this.executeScript(
|
||||
'renewSnowflakeNode',
|
||||
RENEW_SNOWFLAKE_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
instanceId,
|
||||
ttlSeconds,
|
||||
);
|
||||
return parseIntegerDecision(result, 'renewSnowflakeNode');
|
||||
}
|
||||
|
||||
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
|
||||
const result = await this.executeJsonScript(
|
||||
'checkLeakyBucketLimit',
|
||||
@@ -637,31 +566,6 @@ export class KVClient implements IKVProvider {
|
||||
return parseRateLimitResult(result);
|
||||
}
|
||||
|
||||
async tryConsumeTokens(
|
||||
key: string,
|
||||
requested: number,
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<number> {
|
||||
const now = Date.now();
|
||||
const result = await this.executeScript(
|
||||
'tryConsumeTokens',
|
||||
TRY_CONSUME_TOKENS_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
now,
|
||||
requested,
|
||||
maxTokens,
|
||||
refillRate,
|
||||
refillIntervalMs,
|
||||
);
|
||||
if (!isNonNegativeSafeInteger(result) || result > requested) {
|
||||
throw createInvalidResponseError('tryConsumeTokens', 'an integer token count within the requested amount');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
|
||||
const result = await this.executeScript(
|
||||
'scheduleBulkDeletion',
|
||||
|
||||
@@ -258,15 +258,6 @@ export class KVSubscription implements IKVSubscription {
|
||||
}
|
||||
client.disconnect(false);
|
||||
}
|
||||
|
||||
removeAllListeners(event?: 'message' | 'error'): void {
|
||||
if (!event || event === 'message') {
|
||||
this.messageCallbacks.clear();
|
||||
}
|
||||
if (!event || event === 'error') {
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
|
||||
@@ -115,24 +115,12 @@ describe('KVClient script execution', () => {
|
||||
keyCount: 1,
|
||||
run: async (client) => client.extendLock('lock:key', 'token', 30),
|
||||
},
|
||||
{
|
||||
name: 'renewSnowflakeNode',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.renewSnowflakeNode('snowflake:1', 'instance', 30),
|
||||
},
|
||||
{
|
||||
name: 'checkLeakyBucketLimit',
|
||||
reply: RATE_LIMIT_REPLY,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1),
|
||||
},
|
||||
{
|
||||
name: 'tryConsumeTokens',
|
||||
reply: 2,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.tryConsumeTokens('tokens:key', 2, 10, 1, 1000),
|
||||
},
|
||||
{
|
||||
name: 'scheduleBulkDeletion',
|
||||
reply: 1,
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface PostgresQueryable {
|
||||
export interface IPostgresClient extends PostgresQueryable {
|
||||
connect(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T>;
|
||||
kvTable(): string;
|
||||
}
|
||||
@@ -215,10 +214,6 @@ class PostgresClient implements IPostgresClient {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.pool !== null && this.disconnection === null;
|
||||
}
|
||||
|
||||
async query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values: Array<unknown> = [],
|
||||
|
||||
@@ -9,5 +9,4 @@ export interface IWorkerService<TTaskName extends string = string> {
|
||||
options?: WorkerJobOptions,
|
||||
): Promise<bigint>;
|
||||
cancelJob(jobId: bigint): Promise<boolean>;
|
||||
retryDeadLetterJob(jobId: bigint): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -37,24 +37,6 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('buildAPIServerOptions', () => {
|
||||
test('starts the api on the shipped header and request timeouts', async () => {
|
||||
const server = await listenWithEnv();
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('passes the operator header timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('passes the operator request timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(600_000);
|
||||
});
|
||||
|
||||
test('clamps a header timeout set above the request timeout', async () => {
|
||||
const server = await listenWithEnv({
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: '90000',
|
||||
@@ -120,12 +102,6 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
|
||||
).toBe(secret);
|
||||
});
|
||||
|
||||
it('reads the relay secret from the loaded config rather than the environment', () => {
|
||||
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
|
||||
master.services.media_proxy.upload_relay.secret_base64,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
|
||||
@@ -79,27 +79,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function mapApnsApps(
|
||||
apps:
|
||||
| Array<{
|
||||
app_id?: string;
|
||||
topic?: string;
|
||||
environment?: 'production' | 'development';
|
||||
}>
|
||||
| undefined,
|
||||
): APIConfig['push']['apns']['apps'] {
|
||||
return (apps ?? []).map((app) => {
|
||||
if (!app.app_id) {
|
||||
throw new Error('FLUXER_PUSH_APNS_APPS contains an entry with no app_id');
|
||||
}
|
||||
return {
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
|
||||
|
||||
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
|
||||
@@ -318,6 +297,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
url: master.integrations.voice.url,
|
||||
internalUrl: master.integrations.voice.internal_url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
p2pStunUrls: master.integrations.voice.p2p_stun_urls,
|
||||
},
|
||||
stripe: {
|
||||
enabled: master.integrations.stripe.enabled,
|
||||
@@ -396,11 +376,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
rpId: master.auth.passkeys.rp_id,
|
||||
allowedOrigins: master.auth.passkeys.additional_allowed_origins,
|
||||
},
|
||||
vapid: {
|
||||
publicKey: master.auth.vapid.public_key,
|
||||
privateKey: master.auth.vapid.private_key,
|
||||
email: master.auth.vapid.email,
|
||||
},
|
||||
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
|
||||
},
|
||||
klipy: {
|
||||
@@ -455,14 +430,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
push: {
|
||||
publicVapidKey: master.auth.vapid.public_key,
|
||||
apns: {
|
||||
enabled: master.integrations.push.apns.enabled,
|
||||
teamId: master.integrations.push.apns.team_id,
|
||||
keyId: master.integrations.push.apns.key_id,
|
||||
privateKey: master.integrations.push.apns.private_key,
|
||||
privateKeyPath: master.integrations.push.apns.private_key_path,
|
||||
apps: mapApnsApps(master.integrations.push.apns.apps),
|
||||
},
|
||||
},
|
||||
appStore: {
|
||||
enabled: master.integrations.app_store.enabled,
|
||||
|
||||
@@ -338,6 +338,10 @@ import {
|
||||
type UserSettingsRow,
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
type VoiceP2pConnectionReportRow,
|
||||
} from '@app/api/database/types/VoiceTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
@@ -1474,3 +1478,9 @@ export const BillingActionIntents = defineTable<BillingActionIntentRow, 'intent_
|
||||
columns: BILLING_ACTION_INTENT_COLUMNS,
|
||||
primaryKey: ['intent_id'],
|
||||
});
|
||||
export const VoiceP2pConnectionReports = defineTable<VoiceP2pConnectionReportRow, 'user_id' | 'report_id'>({
|
||||
name: 'voice_p2p_connection_reports',
|
||||
columns: VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
primaryKey: ['user_id', 'report_id'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {initializeFluxerErrorMap} from '@app/api/ZodErrorMap';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {beforeAll, describe, expect, it} from 'vitest';
|
||||
import {z} from 'zod';
|
||||
|
||||
function firstIssueMessage(schema: z.ZodType, value: unknown): string | undefined {
|
||||
const result = schema.safeParse(value);
|
||||
return result.success ? undefined : result.error.issues[0]?.message;
|
||||
}
|
||||
|
||||
describe('ZodErrorMap', () => {
|
||||
beforeAll(() => {
|
||||
initializeFluxerErrorMap();
|
||||
});
|
||||
|
||||
it('maps a date below its minimum to INVALID_FORMAT', () => {
|
||||
expect(
|
||||
firstIssueMessage(z.date().min(new Date('2000-01-01T00:00:00.000Z')), new Date('1999-12-31T00:00:00.000Z')),
|
||||
).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps a date above its maximum to INVALID_FORMAT', () => {
|
||||
expect(
|
||||
firstIssueMessage(z.date().max(new Date('2000-01-01T00:00:00.000Z')), new Date('2000-01-02T00:00:00.000Z')),
|
||||
).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps both numeric bounds to INVALID_FORMAT', () => {
|
||||
expect(firstIssueMessage(z.number().min(1), 0)).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
expect(firstIssueMessage(z.number().max(1), 2)).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps a string longer than its maximum to CONTENT_EXCEEDS_MAX_LENGTH', () => {
|
||||
expect(firstIssueMessage(z.string().max(1), 'ab')).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it('maps a string shorter than its minimum to INVALID_FORMAT', () => {
|
||||
expect(firstIssueMessage(z.string().min(2), 'a')).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
});
|
||||
@@ -60,25 +60,10 @@ function getEmailBlocklistKeys(email: string): Array<string> {
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
|
||||
where: BannedPhrases.where.eq('phrase'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select();
|
||||
const IS_URL_BANNED_QUERY = BannedUrls.select({
|
||||
where: BannedUrls.where.eq('url_canonical'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_URLS_QUERY = BannedUrls.select();
|
||||
const IS_URL_DOMAIN_BANNED_QUERY = BannedUrlDomains.select({
|
||||
where: BannedUrlDomains.where.eq('domain'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_URL_DOMAINS_QUERY = BannedUrlDomains.select();
|
||||
const IS_FILE_SHA_BANNED_QUERY = BannedFileShas.select({
|
||||
where: BannedFileShas.where.eq('sha256_hex'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_FILE_SHAS_QUERY = BannedFileShas.select();
|
||||
const IS_AVATAR_HASH_BANNED_QUERY = BannedAvatarHashes.select({
|
||||
where: BannedAvatarHashes.where.eq('hash_short'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_AVATAR_HASHES_QUERY = BannedAvatarHashes.select();
|
||||
const LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY = BannedProfileSubstrings.select();
|
||||
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
|
||||
@@ -274,14 +259,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return rows.map((row) => row.email_lower);
|
||||
}
|
||||
|
||||
async isPhraseBanned(phrase: string): Promise<boolean> {
|
||||
const phraseLower = canonicalizeStoredPhrase(phrase);
|
||||
const result = await fetchOne<{
|
||||
phrase: string;
|
||||
}>(IS_PHRASE_BANNED_QUERY.bind({phrase: phraseLower}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banPhrase(phrase: string): Promise<void> {
|
||||
const phraseLower = canonicalizeStoredPhrase(phrase);
|
||||
await upsertOne(BannedPhrases.insert({phrase: phraseLower}));
|
||||
@@ -299,14 +276,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return rows.map((row) => row.phrase);
|
||||
}
|
||||
|
||||
async isUrlBanned(url: string): Promise<boolean> {
|
||||
const canonical = url.toLowerCase();
|
||||
const result = await fetchOne<{
|
||||
url_canonical: string;
|
||||
}>(IS_URL_BANNED_QUERY.bind({url_canonical: canonical}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banUrl(row: BannedUrlRow): Promise<void> {
|
||||
await upsertOne(BannedUrls.insert({...row, url_canonical: row.url_canonical.toLowerCase()}));
|
||||
}
|
||||
@@ -319,14 +288,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return fetchMany<BannedUrlRow>(LOAD_ALL_BANNED_URLS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
async isUrlDomainBanned(domain: string): Promise<boolean> {
|
||||
const d = domain.toLowerCase();
|
||||
const result = await fetchOne<{
|
||||
domain: string;
|
||||
}>(IS_URL_DOMAIN_BANNED_QUERY.bind({domain: d}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banUrlDomain(row: BannedUrlDomainRow): Promise<void> {
|
||||
await upsertOne(BannedUrlDomains.insert({...row, domain: row.domain.toLowerCase()}));
|
||||
}
|
||||
@@ -339,14 +300,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return fetchMany<BannedUrlDomainRow>(LOAD_ALL_BANNED_URL_DOMAINS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
async isFileShaBanned(sha256Hex: string): Promise<boolean> {
|
||||
const h = sha256Hex.toLowerCase();
|
||||
const result = await fetchOne<{
|
||||
sha256_hex: string;
|
||||
}>(IS_FILE_SHA_BANNED_QUERY.bind({sha256_hex: h}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banFileSha(row: BannedFileShaRow): Promise<void> {
|
||||
await upsertOne(BannedFileShas.insert({...row, sha256_hex: row.sha256_hex.toLowerCase()}));
|
||||
}
|
||||
@@ -368,14 +321,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
async isAvatarHashBanned(hashShort: string): Promise<boolean> {
|
||||
const h = hashShort.toLowerCase();
|
||||
const result = await fetchOne<{
|
||||
hash_short: string;
|
||||
}>(IS_AVATAR_HASH_BANNED_QUERY.bind({hash_short: h}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banAvatarHash(row: BannedAvatarHashRow): Promise<void> {
|
||||
await upsertOne(BannedAvatarHashes.insert({...row, hash_short: row.hash_short.toLowerCase()}));
|
||||
}
|
||||
|
||||
@@ -59,8 +59,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract loadAllBannedEmails(): Promise<Array<string>>;
|
||||
|
||||
abstract isPhraseBanned(phrase: string): Promise<boolean>;
|
||||
|
||||
abstract banPhrase(phrase: string): Promise<void>;
|
||||
|
||||
abstract unbanPhrase(phrase: string): Promise<void>;
|
||||
@@ -69,24 +67,18 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract loadAllBannedIps(): Promise<Set<string>>;
|
||||
|
||||
abstract isUrlBanned(url: string): Promise<boolean>;
|
||||
|
||||
abstract banUrl(row: BannedUrlRow): Promise<void>;
|
||||
|
||||
abstract unbanUrl(url: string): Promise<void>;
|
||||
|
||||
abstract loadAllBannedUrls(): Promise<Array<BannedUrlRow>>;
|
||||
|
||||
abstract isUrlDomainBanned(domain: string): Promise<boolean>;
|
||||
|
||||
abstract banUrlDomain(row: BannedUrlDomainRow): Promise<void>;
|
||||
|
||||
abstract unbanUrlDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract loadAllBannedUrlDomains(): Promise<Array<BannedUrlDomainRow>>;
|
||||
|
||||
abstract isFileShaBanned(sha256Hex: string): Promise<boolean>;
|
||||
|
||||
abstract banFileSha(row: BannedFileShaRow): Promise<void>;
|
||||
|
||||
abstract unbanFileSha(sha256Hex: string): Promise<void>;
|
||||
@@ -95,8 +87,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
|
||||
|
||||
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
|
||||
|
||||
abstract banAvatarHash(row: BannedAvatarHashRow): Promise<void>;
|
||||
|
||||
abstract unbanAvatarHash(hashShort: string): Promise<void>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user