Compare commits

...
21 Commits
Author SHA1 Message Date
Hampus 7d2c51a57f fix(media-proxy): budget native image work by pod memory (#3385) 2026-10-11 02:07:19 +02:00
Hampus 575a5adbef refactor(pkgs): share vitest config and dedupe helpers (#3384) 2026-10-10 23:23:34 +02:00
Hampus 5695510980 refactor(app): merge duplicated components and stores (#3383) 2026-10-10 23:17:29 +02:00
Hampus c16d414d93 refactor(api): collapse single-impl interfaces and facades (#3382) 2026-10-10 23:04:42 +02:00
Hampus e7b13fd19f refactor(gateway): share shard helpers and test fixtures (#3381) 2026-10-10 23:04:30 +02:00
Hampus f22261f218 build(static): fetch spellcheck dictionaries at build (#3380) 2026-10-10 22:46:10 +02:00
Hampus 901ed623f6 refactor(i18n): store reviewed-unchanged ids as a flat list (#3379) 2026-10-10 22:44:42 +02:00
Hampus 1198b61648 refactor(i18n): load server catalogs from weblate json (#3378) 2026-10-10 22:35:45 +02:00
Hampus d564ca5691 refactor(i18n): drop line numbers from po references (#3377) 2026-10-10 22:24:51 +02:00
Hampus b7e92cbfe0 fix(repo): drop orphaned error code and dead voice clause (#3376) 2026-10-10 22:18:12 +02:00
Hampus 1cd62eb661 chore(repo): remove dead code and useless tests (#3375) 2026-10-10 21:43:29 +02:00
Hampus 7e177a1c90 fix(flatpak): allow GNOME idle monitor for away detection (#3374) 2026-10-10 21:17:58 +02:00
Hampus 78471d8a85 fix(app): apply push inactive timeout before desktop goes idle (#3373) 2026-10-10 21:17:37 +02:00
Tarek 6b6ae13919 fix(app): apply instance branding to system DM (#3355) 2026-10-10 20:36:02 +02:00
Hampus 7197371123 fix(gateway): resend thread member lists on resume and leave (#3372) 2026-10-10 19:17:08 +02:00
Hampus 70fd9e6331 refactor(api): drop ignored location param from thread routes (#3371) 2026-10-10 17:57:15 +02:00
Hampus c14b7d4cb3 fix(members): show mobile badges and fresh profiles in lists (#3370) 2026-10-10 17:00:30 +02:00
Hampus 0f0ff5f3df feat(schema): add swipe right to left action setting (#3369) 2026-10-10 16:36:13 +02:00
Hampus b4c614d21a fix(threads): show presence in thread member list (#3368) 2026-10-10 15:58:59 +02:00
Hampus bef5e33eec feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367) 2026-10-10 15:45:17 +02:00
Xeon e5a568acca fix(flatpak): Add permissions necessary for security key/card SSO (#3366) 2026-10-10 14:30:11 +02:00
2371 changed files with 354192 additions and 7872456 deletions

No files matched your search

-13
View File
@@ -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
@@ -86,11 +85,6 @@ services:
target: /workspaces/fluxer/packages/hono/node_modules
volume:
nocopy: true
- type: volume
source: package-hono-types-node-modules
target: /workspaces/fluxer/packages/hono_types/node_modules
volume:
nocopy: true
- type: volume
source: package-i18n-node-modules
target: /workspaces/fluxer/packages/i18n/node_modules
@@ -166,11 +160,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
@@ -355,7 +344,6 @@ volumes:
package-errors-node-modules:
package-geo-utils-node-modules:
package-hono-node-modules:
package-hono-types-node-modules:
package-i18n-node-modules:
package-instance-bootstrap-node-modules:
package-ip-utils-node-modules:
@@ -371,7 +359,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:
+3
View File
@@ -38,6 +38,9 @@
/fluxer_app/src/features/theme/styles/generated/
/fluxer_app/src/features/ui/components/SVGMasks.tsx
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
/fluxer_static/desktop/spellcheck/dictionaries/*/*
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
/fluxer_gateway/priv/
/fluxer_media_proxy/fuzz/artifacts/
+6
View File
@@ -1,6 +1,12 @@
* text=auto
fluxer_static/** -text -diff
fluxer_static/**/*.md text diff
fluxer_static/*.sh text eol=lf diff
fluxer_static/*.sha256 text eol=lf diff
packages/fonts/files/** -text -diff
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.wasm -text -diff
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.tar.gz -text -diff
fluxer_app/src/features/i18n/locales/** linguist-generated
**/weblate/locales/** linguist-generated
fluxer_api/src/api/openapi/openapi.json linguist-generated
fluxer_admin/openapi-admin.json linguist-generated
-3
View File
@@ -67,9 +67,6 @@ p:geo-utils:
p:hono:
- changed-files:
- any-glob-to-any-file: packages/hono/**/*
p:hono-types:
- changed-files:
- any-glob-to-any-file: packages/hono_types/**/*
p:i18n:
- changed-files:
- any-glob-to-any-file: packages/i18n/**/*
-1
View File
@@ -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
+10 -15
View File
@@ -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',
@@ -501,14 +496,14 @@ jobs:
- name: Compile locale catalogs
run: pnpm i18n:compile
- name: Check drift of compiled locale modules
- name: Check drift of synced catalogs
run: |
if ! git diff --exit-code -- \
packages/errors/src/i18n/locales \
fluxer_api/pkgs/email/src/email_i18n/locales \
packages/errors/src/i18n/weblate \
fluxer_api/pkgs/email/src/email_i18n/weblate \
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
fluxer_api/src/api/content_i18n/locales; then
echo "::error::Compiled locale modules are outdated. Run 'pnpm i18n:compile' and commit the result. Translations belong in the weblate/ catalogs, not in the generated locales/ modules."
fluxer_api/src/api/content_i18n/weblate; then
echo "::error::Static catalogs are out of sync with their sources. Run 'pnpm i18n:compile' and commit the result."
exit 1
fi
+3 -2
View File
@@ -35,9 +35,10 @@
/fluxer_app/src/features/ui/components/SVGMasks.tsx
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
/fluxer_gateway/priv/
/fluxer_static/desktop/spellcheck/dictionaries/*/*
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
/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
View File
@@ -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"
-4
View File
@@ -163,7 +163,6 @@
"**",
"!**/.git",
"!**/app.css",
"!fluxer_admin/public/static/app.css",
"!**/build",
"fluxer_app/scripts/build",
"!**/dist",
@@ -184,11 +183,8 @@
"!packages/fonts",
"!fluxer_admin/static/htmx.min.js",
"!fluxer_api/src/api/openapi/openapi.json",
"!fluxer_api/pkgs/email/src/email_i18n/locales",
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
"!fluxer_api/src/api/content_i18n/locales",
"!fluxer_api/src/api/content_i18n/weblate",
"!packages/errors/src/i18n/locales",
"!packages/errors/src/i18n/weblate",
"!**/auto-i18n-reviewed-unchanged.json"
],
-2
View File
@@ -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
-3
View File
@@ -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
+6 -3
View File
@@ -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=
+1 -15
View File
@@ -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:-}
+96 -2
View File
@@ -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",
@@ -17148,7 +17244,6 @@
"CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL",
"CANNOT_SEND_MESSAGES_TO_USER",
"CANNOT_TRANSFER_OWNERSHIP_TO_BOT",
"CANNOT_SHRINK_RESERVED_SLOTS",
"CAPTCHA_REQUIRED",
"CHANNEL_ALREADY_FOLLOWED",
"CHANNEL_HAS_FOLLOWED_CHANNELS",
@@ -17445,7 +17540,6 @@
"You can't send messages in a non-text channel.",
"You can't send messages to this user.",
"Community ownership can't be transferred to a bot.",
"You can't shrink reserved slots.",
"Verification required. Try again.",
"This channel already receives updates from that announcement channel.",
"Remove the followed channels posting here before converting it to an announcement channel.",
+2 -12
View File
@@ -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,
-16
View File
@@ -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,
-5
View File
@@ -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!({})
);
}
}
-5
View File
@@ -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>,
}
-10
View File
@@ -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![]);
+2 -15
View File
@@ -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,
+1 -158
View File
@@ -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(&[
-43
View File
@@ -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());
}
}
+1 -2
View File
@@ -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(),
-63
View File
@@ -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
));
}
}
+237 -13
View File
@@ -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", &params).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", &params).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, &params),
"/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] [&amp;_.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&amp;_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&amp;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", &params(), 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", &params(), true).into_string();
assert!(markup.contains("search-email"));
}
}
-24
View File
@@ -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");
}
}
-70
View File
@@ -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"));
}
}
-14
View File
@@ -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");
}
}
-49
View File
@@ -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");
}
}
-42
View File
@@ -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());
}
}
+25
View File
@@ -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);
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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(),
-635
View File
@@ -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))
}
-172
View File
@@ -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()))
}
-57
View File
@@ -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('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn decode_html_entities(value: &str) -> String {
value
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&#x27;", "'")
.replace("&apos;", "'")
.replace("&quot;", "\"")
.replace("&larr;", "\u{2190}")
.replace("&rarr;", "\u{2192}")
.replace("&mdash;", "\u{2014}")
.replace("&ndash;", "\u{2013}")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
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(&amp_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')
}
-10
View File
@@ -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";
-88
View File
@@ -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}"))
}
+1 -82
View File
@@ -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(),
-242
View File
@@ -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
View File
@@ -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);
}
+1 -16
View File
@@ -1,18 +1,3 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineConfig} from 'vitest/config';
export default defineConfig({
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
export {default} from '../../../tsconfigs/vitest.base.mts';
@@ -1,23 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {types} from 'cassandra-driver';
type CassandraValue =
| string
| number
| bigint
| boolean
| Buffer
| Date
| types.LocalDate
| Set<unknown>
| Map<unknown, unknown>
| Array<unknown>
| Record<string, unknown>
| null;
export type CassandraParams = Record<string, CassandraValue>;
export interface PreparedQuery<P extends CassandraParams = CassandraParams> {
cql: string;
params: P;
}
+4 -71
View File
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CassandraParams, PreparedQuery} from '@pkgs/cassandra/src/CassandraTypes';
import {type Logger, NoopLogger} from '@pkgs/cassandra/src/Logger';
import cassandra from 'cassandra-driver';
const distance = cassandra.types.distance;
@@ -22,47 +20,19 @@ interface CassandraConfig {
readTimeoutMs?: number | undefined;
}
interface CassandraClientOptions {
logger?: Logger | undefined;
}
interface CassandraExecuteOptions {
prepare?: boolean | undefined;
}
interface CassandraBatchOptions {
prepare?: boolean | undefined;
}
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 {
client: CassandraClient | null;
logger: Logger;
}
const defaultClientState: DefaultClientState = {
client: null,
logger: NoopLogger,
};
class CassandraClient implements ICassandraClient {
class CassandraClient {
private readonly config: CassandraConfig;
private logger: Logger;
private client: cassandra.Client | null;
public constructor(config: CassandraConfig, options: CassandraClientOptions = {}) {
public constructor(config: CassandraConfig) {
this.config = {
hosts: [...config.hosts],
port: config.port,
@@ -72,7 +42,6 @@ class CassandraClient implements ICassandraClient {
password: config.password,
readTimeoutMs: config.readTimeoutMs,
};
this.logger = options.logger ?? NoopLogger;
this.client = null;
}
@@ -111,14 +80,6 @@ class CassandraClient implements ICassandraClient {
});
await client.connect();
this.client = client;
this.logger.info(
{
hosts: this.config.hosts,
keyspace: this.config.keyspace,
local_dc: this.config.localDc,
},
'Connected to Cassandra',
);
}
public async shutdown(): Promise<void> {
@@ -128,30 +89,6 @@ class CassandraClient implements ICassandraClient {
const activeClient = this.client;
this.client = null;
await activeClient.shutdown();
this.logger.info({}, 'Cassandra connection closed');
}
public isConnected(): boolean {
return this.client !== null;
}
public async execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options: CassandraExecuteOptions = {},
): Promise<cassandra.types.ResultSet> {
return this.getNativeClient().execute(query.cql, query.params, {
prepare: options.prepare ?? true,
});
}
public async batch(queries: Array<PreparedQuery>, options: CassandraBatchOptions = {}): Promise<void> {
if (queries.length === 0) {
return;
}
const batch = queries.map((query) => ({query: query.cql, params: query.params}));
await this.getNativeClient().batch(batch, {
prepare: options.prepare ?? true,
});
}
public getNativeClient(): cassandra.Client {
@@ -160,17 +97,13 @@ class CassandraClient implements ICassandraClient {
}
return this.client;
}
public setLogger(logger: Logger): void {
this.logger = logger;
}
}
export async function initCassandra(config: CassandraConfig): Promise<void> {
if (defaultClientState.client !== null) {
await defaultClientState.client.shutdown();
}
const client = new CassandraClient(config, {logger: defaultClientState.logger});
const client = new CassandraClient(config);
await client.connect();
defaultClientState.client = client;
}
@@ -183,7 +116,7 @@ export async function shutdownCassandra(): Promise<void> {
defaultClientState.client = null;
}
function getDefaultCassandraClient(): ICassandraClient {
function getDefaultCassandraClient(): CassandraClient {
if (defaultClientState.client === null) {
throw new Error('Cassandra client is not initialized. Call initCassandra() first.');
}
-13
View File
@@ -1,13 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface Logger {
info(context: Record<string, unknown>, message: string): void;
debug(context: Record<string, unknown>, message: string): void;
error(context: Record<string, unknown>, message: string): void;
}
export const NoopLogger: Logger = {
info(): void {},
debug(): void {},
error(): void {},
};
@@ -1,26 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
export interface IEmailI18nService {
getTemplate<T extends EmailTemplateKey>(
templateKey: T,
locale: string | null,
variables: EmailTemplateVariables[T],
productName: string,
): I18nResult<EmailTemplateKey, EmailTemplate>;
}
export class EmailI18nService implements IEmailI18nService {
getTemplate<T extends EmailTemplateKey>(
templateKey: T,
locale: string | null,
variables: EmailTemplateVariables[T],
productName: string,
): I18nResult<EmailTemplateKey, EmailTemplate> {
return getEmailTemplate(templateKey, locale, variables, productName);
}
}
@@ -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']);
});
});
+2 -5
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createLogger} from '@fluxer/logger/src/Logger';
import type {IEmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailConfig, IEmailProvider, UserBouncedEmailChecker} from '@pkgs/email/src/EmailProviderTypes';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import type {
EmailLegalLinks,
EmailTemplateVariables,
@@ -37,18 +37,15 @@ function legalLinkVariables(config: EmailConfig): {
export class EmailService implements IEmailService {
private readonly config: EmailConfig;
private readonly emailI18n: IEmailI18nService;
private readonly provider: IEmailProvider | null;
private readonly bouncedEmailChecker: UserBouncedEmailChecker | null;
constructor(
config: EmailConfig,
emailI18n: IEmailI18nService,
provider: IEmailProvider | null = null,
bouncedEmailChecker: UserBouncedEmailChecker | null = null,
) {
this.config = config;
this.emailI18n = emailI18n;
this.provider = provider;
this.bouncedEmailChecker = bouncedEmailChecker;
}
@@ -394,7 +391,7 @@ export class EmailService implements IEmailService {
locale: string | null,
variables: EmailTemplateVariables[T],
): Promise<boolean> {
const result = this.emailI18n.getTemplate(templateKey, locale, variables, this.config.productName);
const result = getEmailTemplate(templateKey, locale, variables, this.config.productName);
if (!result.ok) {
logger.error({key: templateKey, locale: result.locale, error: result.error}, 'Failed to resolve email template');
return false;
@@ -1,15 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IEmailService} from '@pkgs/email/src/IEmailService';
export interface SentEmailRecord {
to: string;
type: string;
timestamp: Date;
metadata: Record<string, string>;
}
export interface ITestEmailService extends IEmailService {
listSentEmails(): Array<SentEmailRecord>;
clearSentEmails(): void;
}
@@ -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,7 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ReportReceivedTargetKind} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {ITestEmailService, SentEmailRecord} from '@pkgs/email/src/ITestEmailService';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
export interface SentEmailRecord {
to: string;
type: string;
timestamp: Date;
metadata: Record<string, string>;
}
function maskToken(token: string): string {
if (token.length <= 8) {
@@ -12,7 +19,7 @@ function maskToken(token: string): string {
const noopLogger = {info: (_message: string) => {}};
export class TestEmailService implements ITestEmailService {
export class TestEmailService implements IEmailService {
private readonly sentEmails: Array<SentEmailRecord> = [];
private readonly logger: {
info: (message: string) => void;
@@ -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,39 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
import EMAIL_I18N_AR_MESSAGES from '@pkgs/email/src/email_i18n/locales/ar';
import EMAIL_I18N_BG_MESSAGES from '@pkgs/email/src/email_i18n/locales/bg';
import EMAIL_I18N_CS_MESSAGES from '@pkgs/email/src/email_i18n/locales/cs';
import EMAIL_I18N_DA_MESSAGES from '@pkgs/email/src/email_i18n/locales/da';
import EMAIL_I18N_DE_MESSAGES from '@pkgs/email/src/email_i18n/locales/de';
import EMAIL_I18N_EL_MESSAGES from '@pkgs/email/src/email_i18n/locales/el';
import EMAIL_I18N_EN_GB_MESSAGES from '@pkgs/email/src/email_i18n/locales/en-GB';
import EMAIL_I18N_ES_419_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-419';
import EMAIL_I18N_ES_ES_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-ES';
import EMAIL_I18N_FI_MESSAGES from '@pkgs/email/src/email_i18n/locales/fi';
import EMAIL_I18N_FR_MESSAGES from '@pkgs/email/src/email_i18n/locales/fr';
import EMAIL_I18N_HE_MESSAGES from '@pkgs/email/src/email_i18n/locales/he';
import EMAIL_I18N_HI_MESSAGES from '@pkgs/email/src/email_i18n/locales/hi';
import EMAIL_I18N_HR_MESSAGES from '@pkgs/email/src/email_i18n/locales/hr';
import EMAIL_I18N_HU_MESSAGES from '@pkgs/email/src/email_i18n/locales/hu';
import EMAIL_I18N_ID_MESSAGES from '@pkgs/email/src/email_i18n/locales/id';
import EMAIL_I18N_IT_MESSAGES from '@pkgs/email/src/email_i18n/locales/it';
import EMAIL_I18N_JA_MESSAGES from '@pkgs/email/src/email_i18n/locales/ja';
import EMAIL_I18N_KO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ko';
import EMAIL_I18N_LT_MESSAGES from '@pkgs/email/src/email_i18n/locales/lt';
import EMAIL_I18N_NL_MESSAGES from '@pkgs/email/src/email_i18n/locales/nl';
import EMAIL_I18N_NO_MESSAGES from '@pkgs/email/src/email_i18n/locales/no';
import EMAIL_I18N_PL_MESSAGES from '@pkgs/email/src/email_i18n/locales/pl';
import EMAIL_I18N_PT_BR_MESSAGES from '@pkgs/email/src/email_i18n/locales/pt-BR';
import EMAIL_I18N_RO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ro';
import EMAIL_I18N_RU_MESSAGES from '@pkgs/email/src/email_i18n/locales/ru';
import EMAIL_I18N_SV_SE_MESSAGES from '@pkgs/email/src/email_i18n/locales/sv-SE';
import EMAIL_I18N_TH_MESSAGES from '@pkgs/email/src/email_i18n/locales/th';
import EMAIL_I18N_TR_MESSAGES from '@pkgs/email/src/email_i18n/locales/tr';
import EMAIL_I18N_UK_MESSAGES from '@pkgs/email/src/email_i18n/locales/uk';
import EMAIL_I18N_VI_MESSAGES from '@pkgs/email/src/email_i18n/locales/vi';
import EMAIL_I18N_ZH_CN_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-CN';
import EMAIL_I18N_ZH_TW_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-TW';
import EMAIL_I18N_AR_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/ar.json' with {type: 'json'};
import EMAIL_I18N_BG_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/bg.json' with {type: 'json'};
import EMAIL_I18N_CS_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/cs.json' with {type: 'json'};
import EMAIL_I18N_DA_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/da.json' with {type: 'json'};
import EMAIL_I18N_DE_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/de.json' with {type: 'json'};
import EMAIL_I18N_EL_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/el.json' with {type: 'json'};
import EMAIL_I18N_EN_GB_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/en-GB.json' with {type: 'json'};
import EMAIL_I18N_ES_419_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/es-419.json' with {type: 'json'};
import EMAIL_I18N_ES_ES_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/es-ES.json' with {type: 'json'};
import EMAIL_I18N_FI_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/fi.json' with {type: 'json'};
import EMAIL_I18N_FR_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/fr.json' with {type: 'json'};
import EMAIL_I18N_HE_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/he.json' with {type: 'json'};
import EMAIL_I18N_HI_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/hi.json' with {type: 'json'};
import EMAIL_I18N_HR_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/hr.json' with {type: 'json'};
import EMAIL_I18N_HU_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/hu.json' with {type: 'json'};
import EMAIL_I18N_ID_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/id.json' with {type: 'json'};
import EMAIL_I18N_IT_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/it.json' with {type: 'json'};
import EMAIL_I18N_JA_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/ja.json' with {type: 'json'};
import EMAIL_I18N_KO_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/ko.json' with {type: 'json'};
import EMAIL_I18N_LT_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/lt.json' with {type: 'json'};
import EMAIL_I18N_NL_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/nl.json' with {type: 'json'};
import EMAIL_I18N_NO_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/no.json' with {type: 'json'};
import EMAIL_I18N_PL_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/pl.json' with {type: 'json'};
import EMAIL_I18N_PT_BR_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/pt-BR.json' with {type: 'json'};
import EMAIL_I18N_RO_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/ro.json' with {type: 'json'};
import EMAIL_I18N_RU_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/ru.json' with {type: 'json'};
import EMAIL_I18N_SV_SE_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/sv-SE.json' with {type: 'json'};
import EMAIL_I18N_TH_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/th.json' with {type: 'json'};
import EMAIL_I18N_TR_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/tr.json' with {type: 'json'};
import EMAIL_I18N_UK_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/uk.json' with {type: 'json'};
import EMAIL_I18N_VI_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/vi.json' with {type: 'json'};
import EMAIL_I18N_ZH_CN_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/zh-CN.json' with {type: 'json'};
import EMAIL_I18N_ZH_TW_MESSAGES from '@pkgs/email/src/email_i18n/weblate/locales/zh-TW.json' with {type: 'json'};
export const EMAIL_I18N_LOCALE_MESSAGES = {
ar: EMAIL_I18N_AR_MESSAGES,
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
export const EMAIL_I18N_MESSAGES = {
account_deletion_cancelled: {
subject: 'Your {product_name} account is no longer scheduled for deletion',
@@ -104,9 +102,3 @@ export const EMAIL_I18N_MESSAGES = {
body: 'Hello {username},\n\nGood news: your {product_name} account suspension has been lifted.\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}You can now log back in and continue using {product_name} as normal.\n\n– {product_name} Safety Team',
},
} as const;
export function defineEmailI18nLocaleMessages<const TMessages extends Partial<Record<EmailTemplateKey, EmailTemplate>>>(
messages: TMessages,
): TMessages {
return messages;
}
@@ -1,108 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
"account_deletion_cancelled": {
"subject": "تم إلغاء حذف حسابك في {product_name}",
"body": "مرحبًا {username}،\n\nتم إلغاء الحذف المجدول لحسابك في {product_name}. لن يتم حذف حسابك.\n\n{safety_email, select, null {إذا كانت لديك أي أسئلة، يرجى التواصل مع مسؤولي هذا المثيل.} other {إذا كانت لديك أي أسئلة، يرجى الاتصال بـ{safety_email}.}}\n\n– فريق {product_name}"
},
"account_deletion_scheduled_inactivity": {
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
"body": "مرحبًا {username}،\n\nلم يكن حسابك في {product_name} نشطًا لفترة طويلة، لذا تمت جدولته للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\n{safety_email, select, null {إذا كنت ترغب في الاحتفاظ بحسابك، فتواصل مع مسؤولي هذا المثيل قبل ذلك التاريخ.} other {إذا كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.}}\n\n– فريق {product_name}"
},
"account_deletion_scheduled_requested": {
"subject": "تمت جدولة حذف حسابك في {product_name}",
"body": "مرحبًا {username}،\n\nبناءً على طلبك، تمت جدولة حسابك في {product_name} للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nسيظل حسابك مقفلًا حتى ذلك الحين. {safety_email, select, null {إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فتواصل مع مسؤولي هذا المثيل قبل ذلك التاريخ.} other {إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.}}\n\n– فريق {product_name}"
},
"account_scheduled_deletion": {
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}هذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\n{legalLinks, select,\n both {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n terms {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n\n}\n guidelines {يرجى مراجعة:\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n other {}\n}عملية الاستئناف:\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.}}\n\nفي استئنافك:\n- اشرح بوضوح سبب اعتقادك أن قرار الإنفاذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق مؤقتًا إلى حين التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
},
"account_temp_banned": {
"subject": "تم تعليق حسابك في {product_name} مؤقتًا",
"body": "مرحبًا {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة}\n zero {# ساعة}\n two {ساعتان}\n few {# ساعات}\n many {# ساعة}\n other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}خلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\n{legalLinks, select,\n both {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n terms {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n\n}\n guidelines {يرجى مراجعة:\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n other {}\n}إذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح.}} سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
},
"donation_confirmation": {
"subject": "شكرًا لك على تبرعك لـ{product_name}",
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
},
"donation_magic_link": {
"subject": "إدارة تبرعاتك لـ{product_name}",
"body": "مرحبًا،\n\nانقر على الرابط أدناه للوصول إلى بوابة المتبرعين الخاصة بك:\n\n{manageUrl}\n\nفي البوابة، يمكنك إدارة الاشتراكات، وتنزيل الفواتير، وعرض سجل تبرعاتك.\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا الرابط، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\n– فريق {product_name}"
},
"dsa_report_resolved": {
"subject": "اتخذنا قرارًا بشأن بلاغك في {product_name}",
"body": "مرحبًا،\n\nراجع فريق الأمان لدينا بلاغك بموجب قانون الخدمات الرقمية (المعرف: {reportId}) واتخذ قرارًا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nلا نشارك تفاصيل أي إجراء يُتخذ بحق حساب شخص آخر، لأنها بياناته الشخصية.\n\nإذا كنت لا توافق على هذا القرار، يمكنك تقديم استئناف مجانًا خلال 60 يومًا. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل، واذكر معرف بلاغك، واشرح سبب اعتقادك أن القرار غير صحيح.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا، واذكر معرف بلاغك، واشرح سبب اعتقادك أن القرار غير صحيح.}} إذا كنت في الاتحاد الأوروبي، يمكنك أيضًا إحالة النزاع إلى هيئة معتمدة لتسوية المنازعات خارج المحكمة. لا يؤثر أي من ذلك على حقك في اللجوء إلى القضاء.\n\n– فريق أمان {product_name}"
},
"dsa_report_verification": {
"subject": "تحقق من بريدك الإلكتروني لتقديم بلاغ DSA",
"body": "مرحبًا،\n\nاستخدم رمز التحقق أدناه لتقديم بلاغك بموجب قانون الخدمات الرقمية على {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق أمان {product_name}"
},
"email_change_new": {
"subject": "تحقق من بريدك الإلكتروني الجديد في {product_name}",
"body": "مرحبًا {username}،\n\nأدخل هذا الرمز في التطبيق للتحقق من بريدك الإلكتروني الجديد في {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق {product_name}"
},
"email_change_original": {
"subject": "تأكيد تغيير بريدك الإلكتروني في {product_name}",
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير عنوان البريد الإلكتروني لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يرجى تأمين حسابك على الفور.\n\n– فريق {product_name}"
},
"email_change_revert": {
"subject": "تم تغيير بريدك الإلكتروني في {product_name}",
"body": "مرحبًا {username}،\n\nتم تغيير عنوان البريد الإلكتروني لحسابك في {product_name} إلى {newEmail}.\n\nإذا قمت بهذا التغيير، فلا داعي لاتخاذ أي إجراء. إذا لم تقم بذلك، يمكنك التراجع عن التغيير وتأمين حسابك باستخدام هذا الرابط:\n\n{revertUrl}\n\nسيؤدي هذا إلى استعادة بريدك الإلكتروني السابق، وتسجيل خروجك من جميع الأجهزة، وتعطيل المصادقة متعددة العوامل، وسيُطلب منك تعيين كلمة مرور جديدة.\n\n– فريق أمان {product_name}"
},
"email_verification": {
"subject": "تحقق من بريدك الإلكتروني في {product_name}",
"body": "مرحبًا {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
},
"gift_chargeback_notification": {
"subject": "تمت إزالة مزايا الهدية التي استرددتها",
"body": "مرحبًا {username}،\n\nدفع شخص آخر في الأصل ثمن رمز الهدية الذي استرددته، وقد تم التراجع عن تلك الدفعة لاحقًا (رد مبلغ مدفوع).\n\nولهذا السبب أزلنا المزايا التي أُضيفت إلى حسابك عند استرداد الهدية.\n\n{support_email, select, null {إذا كنت تعتقد أن هذا خطأ، يرجى التواصل مع مسؤولي هذا المثيل مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.} other {إذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.}}\n\n– فريق {product_name}"
},
"harvest_completed": {
"subject": "تصدير بياناتك من {product_name} جاهز للتنزيل",
"body": "مرحبًا {username}،\n\nتصدير بياناتك جاهز.\n\nرابط التنزيل:\n{downloadUrl}\n\nالرسائل المتضمنة: {totalMessages, number}\nحجم الملف: {fileSizeMB, number} ميجابايت\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\n{support_email, select, null {إذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والتواصل مع مسؤولي هذا المثيل.} other {إذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والاتصال بفريق الدعم لدينا.}}\n\n– فريق {product_name}"
},
"inactivity_warning": {
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
"body": "مرحبًا {username}،\n\nلم نرَ أي نشاط على حسابك في {product_name} منذ {lastActiveDate, date, full}.\n\nإذا لم تسجل الدخول بحلول {deletionDate, date, full} الساعة {deletionDate, time, short}، فسيتم حذف حسابك نهائيًا بسبب عدم النشاط.\n\nسجل الدخول هنا:\n{loginUrl}\n\n{support_email, select, null {إذا كنت قد استخدمت {product_name} مؤخرًا، يرجى التواصل مع مسؤولي هذا المثيل على الفور.} other {إذا كنت قد استخدمت {product_name} مؤخرًا، يرجى الاتصال بفريق الدعم لدينا على الفور.}}\n\n– فريق {product_name}"
},
"ip_authorization": {
"subject": "السماح بتسجيل الدخول من عنوان IP جديد",
"body": "مرحبًا {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى السماح بعنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لمدة 30 دقيقة.\n\n– فريق {product_name}"
},
"mfa_backup_codes_view": {
"subject": "تأكيد الوصول إلى الرموز الاحتياطية في {product_name}",
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لعرض الرموز الاحتياطية لحسابك في {product_name}.\n\nلتأكيد هذا الطلب، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور.\n\n– فريق {product_name}"
},
"password_change_verification": {
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
},
"password_reset": {
"subject": "إعادة تعيين كلمة المرور في {product_name}",
"body": "مرحبًا {username}،\n\nلقد طلبت إعادة تعيين كلمة المرور الخاصة بك في {product_name}. استخدم الرابط أدناه لتعيين كلمة مرور جديدة:\n\n{resetUrl}\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة ساعة واحدة.\n\n– فريق {product_name}"
},
"report_received": {
"subject": "لقد استلمنا بلاغك في {product_name}",
"body": "مرحبًا،\n\nلقد استلمنا بلاغك بموجب قانون الخدمات الرقمية بشأن {targetKind, select, message {رسالة} user {حساب} guild {مجتمع} other {محتوى}} على {product_name}.\n\nمعرف البلاغ: {reportId}\n\nسيراجع فريق الأمان لدينا بلاغك وسنراسلك على هذا العنوان عندما نتخذ قرارًا. احتفظ بهذه الرسالة للرجوع إليها.\n\n– فريق أمان {product_name}"
},
"report_resolved": {
"subject": "تمت مراجعة بلاغك في {product_name}",
"body": "مرحبًا {username}،\n\nتمت مراجعة بلاغك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع البلاغات على محمل الجد ونقدر مساهمتك في المجتمع.\n\n{safety_email, select, null {إذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى التواصل مع مسؤولي هذا المثيل.} other {إذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ{safety_email}.}}\n\n– فريق أمان {product_name}"
},
"scheduled_deletion_notification": {
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}هذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح، يمكنك تقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.}}\n\n– فريق أمان {product_name}"
},
"self_deletion_scheduled": {
"subject": "تمت جدولة حذف حسابك في {product_name}",
"body": "مرحبًا {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تمت جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
},
"unban_notification": {
"subject": "تم رفع تعليق حسابك في {product_name}",
"body": "مرحبًا {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}يمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
}
});
export default EMAIL_I18N_AR_MESSAGES;
@@ -1,108 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
"account_deletion_cancelled": {
"subject": "Изтриването на акаунта ти във {product_name} е отменено",
"body": "Здравей, {username},\n\nНасроченото изтриване на акаунта ти във {product_name} е отменено. Акаунтът ти няма да бъде изтрит.\n\n{safety_email, select, null {Ако имаш въпроси, свържи се с администраторите на тази инстанция.} other {Ако имаш въпроси, свържи се с {safety_email}.}}\n\n– Екип на {product_name}"
},
"account_deletion_scheduled_inactivity": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е неактивен от дълго време, затова е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\n{safety_email, select, null {Ако искаш да запазиш акаунта си, свържи се с администраторите на тази инстанция преди тази дата.} other {Ако искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.}}\n\n– Екип на {product_name}"
},
"account_deletion_scheduled_requested": {
"subject": "Изтриването на акаунта ти във {product_name} е насрочено",
"body": "Здравей, {username},\n\nКакто поиска, акаунтът ти във {product_name} е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nДотогава акаунтът ти е заключен. {safety_email, select, null {Ако не си поискал това или искаш да запазиш акаунта си, свържи се с администраторите на тази инстанция преди тази дата.} other {Ако не си поискал това или искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.}}\n\n– Екип на {product_name}"
},
"account_scheduled_deletion": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Общи условия или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Това е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\n{legalLinks, select,\n both {Прегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\n}\n terms {Прегледай:\n- Общи условия: {termsUrl}\n\n}\n guidelines {Прегледай:\n- Насоки на общността: {guidelinesUrl}\n\n}\n other {}\n}Процес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция.} other {Изпрати имейл на {appeals_email} от този имейл адрес.}}\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Приложи всички релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
},
"account_temp_banned": {
"subject": "Акаунтът ти във {product_name} е временно спрян",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Общи условия или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}През това време няма да имаш достъп до акаунта си.\n\n{legalLinks, select,\n both {Прегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\n}\n terms {Прегледай:\n- Общи условия: {termsUrl}\n\n}\n guidelines {Прегледай:\n- Насоки на общността: {guidelinesUrl}\n\n}\n other {}\n}Ако смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция и ясно обясни защо смяташ, че решението е неправилно.} other {Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно.}} Ще прегледаме обжалването ти и ще ти отговорим с решението си.\n\n– Екип за безопасност на {product_name}"
},
"donation_confirmation": {
"subject": "Благодарим ти за дарението към {product_name}",
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
},
"donation_magic_link": {
"subject": "Управлявай даренията си за {product_name}",
"body": "Здравей,\n\nКликни върху линка по-долу, за да получиш достъп до портала за дарители:\n\n{manageUrl}\n\nВ портала можеш да управляваш абонаменти, да изтегляш фактури и да преглеждаш историята на даренията си.\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този линк, можеш спокойно да игнорираш този имейл.\n\n– Екип на {product_name}"
},
"dsa_report_resolved": {
"subject": "Взехме решение по сигнала ти във {product_name}",
"body": "Здравей,\n\nНашият екип за безопасност прегледа сигнала ти по Закона за цифровите услуги (ID: {reportId}) и взе решение.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nНе споделяме подробности за действия, предприети срещу акаунта на друг човек, защото това са негови лични данни.\n\nАко не си съгласен с това решение, можеш да го обжалваш безплатно в срок от 60 дни. {appeals_email, select, null {Свържи се с администраторите на тази инстанция, посочи ID на сигнала си и обясни защо смяташ, че решението е неправилно.} other {Изпрати имейл на {appeals_email} от този имейл адрес, посочи ID на сигнала си и обясни защо смяташ, че решението е неправилно.}} Ако се намираш в ЕС, можеш също да отнесеш спора към сертифициран орган за извънсъдебно решаване на спорове. Нищо от това не засяга правото ти да се обърнеш към съда.\n\n– Екип за безопасност на {product_name}"
},
"dsa_report_verification": {
"subject": "Потвърди имейла си за сигнал по DSA",
"body": "Здравей,\n\nИзползвай кода за потвърждение по-долу, за да подадеш сигнала си по Закона за цифровите услуги във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип за безопасност на {product_name}"
},
"email_change_new": {
"subject": "Потвърди новия си имейл във {product_name}",
"body": "Здравей, {username},\n\nВъведи този код в приложението, за да потвърдиш новия си имейл във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип на {product_name}"
},
"email_change_original": {
"subject": "Потвърди промяната на имейла си във {product_name}",
"body": "Здравей, {username},\n\nПолучихме искане за промяна на имейл адреса на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, защити акаунта си незабавно.\n\n– Екип на {product_name}"
},
"email_change_revert": {
"subject": "Имейлът ти във {product_name} беше променен",
"body": "Здравей, {username},\n\nИмейл адресът на акаунта ти във {product_name} беше променен на {newEmail}.\n\nАко ти си направил тази промяна, не е необходимо да предприемаш никакви действия. Ако не си, можеш да отмениш промяната и да защитиш акаунта си, като използваш този линк:\n\n{revertUrl}\n\nТова ще възстанови предишния ти имейл, ще прекрати всички активни сесии, ще деактивира MFA и ще изиска да зададеш нова парола.\n\n– Екип за безопасност на {product_name}"
},
"email_verification": {
"subject": "Потвърди имейл адреса си във {product_name}",
"body": "Здравей, {username},\n\nПотвърди имейл адреса за акаунта си във {product_name}, като кликнеш върху линка по-долу:\n\n{verifyUrl}\n\nАко не си създал акаунт във {product_name}, можеш спокойно да игнорираш този имейл.\n\nТози линк е валиден 24 часа.\n\n– Екип на {product_name}"
},
"gift_chargeback_notification": {
"subject": "Предимствата от осребрения ти подарък са премахнати",
"body": "Здравей, {username},\n\nКод за подарък, който си осребрил, първоначално е бил платен от някой друг. Оттогава плащането е върнато чрез оспорване на трансакция.\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато осребри подаръка.\n\n{support_email, select, null {Ако смяташ, че това е грешка, свържи се с администраторите на тази инстанция и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.} other {Ако смяташ, че това е грешка, свържи се с нашия екип за поддръжка и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.}}\n\n– Екип на {product_name}"
},
"harvest_completed": {
"subject": "Експортът на данните ти от {product_name} е готов за изтегляне",
"body": "Здравей, {username},\n\nЕкспортът на данните ти е готов.\n\nЛинк за изтегляне:\n{downloadUrl}\n\nВключени съобщения: {totalMessages, number}\nРазмер на файла: {fileSizeMB, number} MB\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\n{support_email, select, null {Ако не си поискал този експорт, смени паролата си незабавно и се свържи с администраторите на тази инстанция.} other {Ако не си поискал този експорт, смени паролата си незабавно и се свържи с нашия екип за поддръжка.}}\n\n– Екип на {product_name}"
},
"inactivity_warning": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
"body": "Здравей, {username},\n\nНе сме забелязали активност в акаунта ти във {product_name} от {lastActiveDate, date, full}.\n\nАко не влезеш до {deletionDate, date, full} в {deletionDate, time, short}, акаунтът ти ще бъде изтрит за постоянно поради неактивност.\n\nВлез тук:\n{loginUrl}\n\n{support_email, select, null {Ако си използвал {product_name} наскоро, свържи се с администраторите на тази инстанция незабавно.} other {Ако си използвал {product_name} наскоро, свържи се с нашия екип за поддръжка незабавно.}}\n\n– Екип на {product_name}"
},
"ip_authorization": {
"subject": "Разреши влизане от нов IP адрес",
"body": "Здравей, {username},\n\nОткрихме опит за влизане в акаунта ти във {product_name} от нов IP адрес:\n\nIP адрес: {ipAddress}\nМестоположение: {location}\n\nАко това си бил ти, разреши този IP адрес, като кликнеш върху линка по-долу:\n\n{authUrl}\n\nАко не си се опитвал да влезеш, смени паролата си незабавно.\n\nТози линк е валиден 30 минути.\n\n– Екип на {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Потвърди достъпа до кодовете си за възстановяване във {product_name}",
"body": "Здравей, {username},\n\nПолучихме искане за преглед на кодовете за възстановяване на акаунта ти във {product_name}.\n\nЗа да потвърдиш това искане, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно.\n\n– Екип на {product_name}"
},
"password_change_verification": {
"subject": "Потвърди промяната на паролата си във {product_name}",
"body": "Здравей, {username},\n\nПолучихме искане за промяна на паролата на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно и активирай двуфакторно удостоверяване.\n\n– Екип на {product_name}"
},
"password_reset": {
"subject": "Нулирай паролата си във {product_name}",
"body": "Здравей, {username},\n\nПоискал си нулиране на паролата си във {product_name}. Използвай линка по-долу, за да зададеш нова парола:\n\n{resetUrl}\n\nАко не си го поискал, можеш спокойно да игнорираш този имейл.\n\nТози линк е валиден 1 час.\n\n– Екип на {product_name}"
},
"report_received": {
"subject": "Получихме сигнала ти във {product_name}",
"body": "Здравей,\n\nПолучихме сигнала ти по Закона за цифровите услуги относно {targetKind, select, message {съобщение} user {акаунт} guild {общност} other {съдържание}} във {product_name}.\n\nID на сигнала: {reportId}\n\nНашият екип за безопасност ще прегледа сигнала ти и ще ти пишем на този адрес, когато вземем решение. Запази този имейл за справка.\n\n– Екип за безопасност на {product_name}"
},
"report_resolved": {
"subject": "Сигналът ти във {product_name} е прегледан",
"body": "Здравей, {username},\n\nСигналът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш на {product_name} да остане безопасно място за всички. Приемаме всички сигнали сериозно и ценим приноса ти към общността.\n\n{safety_email, select, null {Ако имаш въпроси или притеснения относно този резултат, свържи се с администраторите на тази инстанция.} other {Ако имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.}}\n\n– Екип за безопасност на {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Това е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nАко смяташ, че това решение е неправилно, можеш да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция.} other {Изпрати имейл на {appeals_email} от този имейл адрес.}}\n\n– Екип за безопасност на {product_name}"
},
"self_deletion_scheduled": {
"subject": "Изтриването на акаунта ти във {product_name} е насрочено",
"body": "Здравей, {username},\n\nПоискал си изтриване на акаунта си във {product_name}. Акаунтът ти е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}\n\nАко не си поискал това, влез в акаунта си, за да отмениш изтриването. Препоръчваме също да смениш паролата си, за да защитиш акаунта си.\n\n– Екип на {product_name}"
},
"unban_notification": {
"subject": "Спирането на акаунта ти във {product_name} е отменено",
"body": "Здравей, {username},\n\nДобра новина: спирането на акаунта ти във {product_name} е отменено.\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Вече можеш да влезеш отново и да продължиш да използваш {product_name} както обикновено.\n\n– Екип за безопасност на {product_name}"
}
});
export default EMAIL_I18N_BG_MESSAGES;
Loaded 100 of 2371 files, more files were not shown because too many files have changed in this diff. Show more