Compare commits

..
Author SHA1 Message Date
Hampus 44bc29b101 fix(media-proxy): let video work fit small memory budgets (#3392) 2026-10-11 14:14:59 +02:00
Hampus b07dcc368d fix(app): read instance theme color without an active runtime (#3391) 2026-10-11 13:37:05 +02:00
Hampus 0967bf3136 fix(api): SSO discovery, channel create and deleted bots (#3390) 2026-10-11 13:31:18 +02:00
Hampus edc913096a fix(selfhost): branding image origins and theme colour (#3389) 2026-10-11 13:31:05 +02:00
Hampus cca8227ca6 fix(email): test saved SMTP config and add TLS modes (#3388) 2026-10-11 13:30:00 +02:00
Hampus 45b78a13c2 fix(media): stream large files and honour size settings (#3387) 2026-10-11 13:28:24 +02:00
Hampus 6acd7f3d22 fix(admin): save premium limits and model changes (#3386) 2026-10-11 13:26:39 +02:00
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
2341 changed files with 348620 additions and 7874034 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
+10 -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]]
@@ -1805,6 +1803,7 @@ dependencies = [
"http-body-util",
"hyper",
"libc",
"memmap2",
"moka",
"parking_lot",
"percent-encoding",
@@ -2017,24 +2016,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 +2629,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 +2877,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"
@@ -2919,6 +2893,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
-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"
],
+1 -3
View File
@@ -104,7 +104,7 @@ FLUXER_EMAIL_SMTP_HOST=mailpit
FLUXER_EMAIL_SMTP_PORT=1025
FLUXER_EMAIL_SMTP_USERNAME=dev
FLUXER_EMAIL_SMTP_PASSWORD=dev
FLUXER_EMAIL_SMTP_SECURE=false
FLUXER_EMAIL_SMTP_TLS_MODE=none
FLUXER_SEARCH_ENGINE=meilisearch
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
@@ -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
+7 -2
View File
@@ -318,7 +318,13 @@ FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
FLUXER_EMAIL_SMTP_PASSWORD=
FLUXER_EMAIL_SMTP_SECURE=true
# How the SMTP connection is encrypted: implicit, starttls, opportunistic or
# none. Leave it empty for implicit TLS on port 465 and required STARTTLS on any
# other port.
FLUXER_EMAIL_SMTP_TLS_MODE=
# The older switch, read only while the mode above is empty. true is implicit
# and false is opportunistic.
#FLUXER_EMAIL_SMTP_SECURE=false
#FLUXER_EMAIL_WEBHOOK_SECRET=
FLUXER_DISCOVERY_ENABLED=true
@@ -377,7 +383,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.
+6 -13
View File
@@ -121,6 +121,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
@@ -656,25 +657,17 @@ 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:-}
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
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:-}
+66 -15
View File
@@ -4144,10 +4144,10 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.",
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}}
}
}
@@ -11523,9 +11523,34 @@
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
"username": {"nullable": true, "type": "string"},
"password_set": {"type": "boolean"},
"secure": {"nullable": true, "type": "boolean"}
"tls_mode": {
"nullable": true,
"description": "Saved TLS mode, or null to pick one from the port",
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
"x-enumDescriptions": [
"TLS from the first byte, usually on port 465",
"Plain connection that must upgrade with STARTTLS, usually on port 587",
"Plain connection upgraded with STARTTLS when the server offers it",
"No TLS, even when the server offers STARTTLS"
],
"enum": ["implicit", "starttls", "opportunistic", "none"],
"type": "string"
},
"effective_tls_mode": {
"nullable": true,
"description": "How the SMTP connection is encrypted",
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
"x-enumDescriptions": [
"TLS from the first byte, usually on port 465",
"Plain connection that must upgrade with STARTTLS, usually on port 587",
"Plain connection upgraded with STARTTLS when the server offers it",
"No TLS, even when the server offers STARTTLS"
],
"enum": ["implicit", "starttls", "opportunistic", "none"],
"type": "string"
}
},
"required": ["host", "port", "username", "password_set", "secure"],
"required": ["host", "port", "username", "password_set", "tls_mode", "effective_tls_mode"],
"additionalProperties": false
},
"disable_new_ip_authorization": {"type": "boolean"},
@@ -11788,13 +11813,24 @@
"InstanceEmailSmtpTestRequest": {
"type": "object",
"properties": {
"host": {"type": "string", "minLength": 1, "maxLength": 255},
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
"username": {"type": "string", "minLength": 1, "maxLength": 320},
"password": {"type": "string", "minLength": 1, "maxLength": 4096},
"secure": {"default": true, "type": "boolean"}
},
"required": ["host", "port", "username", "password"]
"host": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 255},
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
"username": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 320},
"password": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"tls_mode": {
"nullable": true,
"description": "How the SMTP connection is encrypted",
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
"x-enumDescriptions": [
"TLS from the first byte, usually on port 465",
"Plain connection that must upgrade with STARTTLS, usually on port 587",
"Plain connection upgraded with STARTTLS when the server offers it",
"No TLS, even when the server offers STARTTLS"
],
"enum": ["implicit", "starttls", "opportunistic", "none"],
"type": "string"
}
}
},
"InstanceEmailSmtpTestResponse": {
"type": "object",
@@ -11869,7 +11905,11 @@
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"theme_color": {
"nullable": true,
"type": "string",
"pattern": "^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$"
},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
@@ -11923,7 +11963,20 @@
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
"username": {"nullable": true, "type": "string", "maxLength": 320},
"password": {"nullable": true, "type": "string", "maxLength": 4096},
"secure": {"nullable": true, "type": "boolean"}
"tls_mode": {
"nullable": true,
"description": "How the SMTP connection is encrypted",
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
"x-enumDescriptions": [
"TLS from the first byte, usually on port 465",
"Plain connection that must upgrade with STARTTLS, usually on port 587",
"Plain connection upgraded with STARTTLS when the server offers it",
"No TLS, even when the server offers STARTTLS"
],
"enum": ["implicit", "starttls", "opportunistic", "none"],
"type": "string"
},
"secure": {"description": "Deprecated, use tls_mode", "nullable": true, "type": "boolean"}
}
},
"disable_new_ip_authorization": {"nullable": true, "type": "boolean"}
@@ -17244,7 +17297,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",
@@ -17541,7 +17593,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,
+3 -4
View File
@@ -4,7 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -34,11 +34,10 @@ impl AdminApiClient {
.await
}
pub async fn test_instance_smtp_config(
pub async fn test_saved_instance_smtp_config(
&self,
request: &InstanceEmailSmtpTestRequest,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
self.post_typed("/admin/instance/config/smtp-tests", request)
self.post_typed("/admin/instance/config/smtp-tests", &serde_json::json!({}))
.await
}
-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,
+3 -11
View File
@@ -229,7 +229,8 @@ pub struct InstanceEmailSmtpIntegrationResponse {
pub username: Option<String>,
#[serde(default)]
pub password_set: bool,
pub secure: Option<bool>,
pub tls_mode: Option<String>,
pub effective_tls_mode: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -873,7 +874,7 @@ pub struct InstanceEmailSmtpIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
pub tls_mode: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -928,15 +929,6 @@ pub struct InstanceAttachmentDecayUpdateRequest {
pub renew_window_days: Option<u32>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceEmailSmtpTestRequest {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub secure: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceEmailSmtpTestResponse {
#[serde(default)]
-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
));
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
}
}
fn validation_message(error: &ApiError) -> Option<String> {
pub(super) fn validation_message(error: &ApiError) -> Option<String> {
let ApiError::Http {
status: 400,
message,
+248 -48
View File
@@ -14,11 +14,11 @@ use crate::{
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
},
@@ -233,21 +233,19 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"test_smtp" => match build_smtp_test_request(&form) {
Ok(request) => match client.test_instance_smtp_config(&request).await {
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
Ok(response) => FlashData::error(
response
.error
.unwrap_or_else(|| "SMTP validation failed".to_owned()),
),
Err(error) => {
tracing::warn!(%error, "admin API request failed: test SMTP config");
FlashData::error("Failed to validate SMTP configuration")
}
},
Err(message) => FlashData::error(message),
},
"test_smtp" => {
let update = build_integrations_update(&form);
match client.update_instance_config(&update).await {
Ok(_) => match client.test_saved_instance_smtp_config().await {
Ok(response) => smtp_test_flash(response),
Err(error) => {
tracing::warn!(%error, "admin API request failed: test SMTP config");
FlashData::error("Settings saved, but the SMTP test could not run")
}
},
Err(error) => instance_config_result(Err::<(), _>(error)),
}
}
"disable_single_community" => {
let update = build_single_community_update(false);
instance_config_result(client.update_instance_config(&update).await)
@@ -405,16 +403,33 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
response
}
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) -> FlashData {
match result {
Ok(_) => FlashData::success("Instance config updated"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update instance config");
FlashData::error("Failed to update instance config")
match super::billing_actions::validation_message(&error) {
Some(message) => {
FlashData::error(format!("Failed to update instance config: {message}"))
}
None => FlashData::error("Failed to update instance config"),
}
}
}
}
fn smtp_test_flash(response: InstanceEmailSmtpTestResponse) -> FlashData {
if response.ok {
return FlashData::success("Settings saved and SMTP connection verified");
}
let reason = response
.error
.unwrap_or_else(|| "the server gave no reason".to_owned());
FlashData::error(format!(
"Settings saved, but the SMTP test failed: {reason}"
))
}
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let flag = |key: &str| form.bool_value(key);
let get = |key: &str| Some(form.clean(key));
@@ -891,7 +906,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
port: smtp_port,
username: clean("integration_smtp_username"),
password: clean("integration_smtp_password"),
secure: Some(form.bool_value("integration_smtp_secure")),
tls_mode: form
.first("integration_smtp_tls_mode")
.map(|value| Some(value.trim().to_owned()).filter(|mode| !mode.is_empty())),
}),
disable_new_ip_authorization: Some(
form.bool_value("integration_email_disable_new_ip_authorization"),
@@ -934,29 +951,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
}
}
fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTestRequest, String> {
let host = form
.clean("integration_smtp_host")
.ok_or_else(|| "SMTP host is required".to_owned())?;
let port = form
.first("integration_smtp_port")
.and_then(|value| value.trim().parse::<u16>().ok())
.ok_or_else(|| "SMTP port must be between 1 and 65535".to_owned())?;
let username = form
.clean("integration_smtp_username")
.ok_or_else(|| "SMTP username is required".to_owned())?;
let password = form
.clean("integration_smtp_password")
.ok_or_else(|| "SMTP password is required for validation".to_owned())?;
Ok(InstanceEmailSmtpTestRequest {
host,
port,
username,
password,
secure: form.bool_value("integration_smtp_secure"),
})
}
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
@@ -1042,6 +1036,7 @@ fn update_limit_rule_values(
rule: &mut LimitRule,
form: &MultiValueForm,
limit_keys: &[String],
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
) {
let mut limits = std::collections::BTreeMap::new();
@@ -1055,12 +1050,19 @@ fn update_limit_rule_values(
{
limits.extend(defaults.clone());
}
for key in limit_keys {
if metadata.get(key).is_some_and(|metadata| metadata.is_toggle)
&& rule.limits.contains_key(key)
{
limits.entry(key.clone()).or_insert(0);
}
}
rule.limits = limits;
rule.filters = build_limit_filters(form);
}
fn limit_config_result<T, E: std::fmt::Display>(
result: Result<T, E>,
fn limit_config_result<T>(
result: Result<T, crate::api::client::ApiError>,
success_message: &'static str,
error_message: &'static str,
) -> FlashData {
@@ -1068,7 +1070,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
Ok(_) => FlashData::success(success_message),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update limit config");
FlashData::error(error_message)
match super::billing_actions::validation_message(&error) {
Some(message) => FlashData::error(format!("{error_message}: {message}")),
None => FlashData::error(error_message),
}
}
}
}
@@ -1137,7 +1142,13 @@ pub async fn limit_config_post(
.defaults
.get(&rule_id)
.or_else(|| current.defaults.get("default"));
update_limit_rule_values(rule, &form, &current.limit_keys, fallback);
update_limit_rule_values(
rule,
&form,
&current.limit_keys,
&current.metadata,
fallback,
);
let request = LimitConfigUpdateRequest { limit_config };
let result = client.update_limit_config(&request).await;
let flash = limit_config_result(
@@ -1147,6 +1158,43 @@ pub async fn limit_config_post(
);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"reset" => {
let rule = aq
.rule
.as_deref()
.and_then(clean_string)
.and_then(|rule_id| {
limit_config
.rules
.iter_mut()
.find(|rule| rule.id == rule_id)
});
let Some(rule) = rule else {
return redirect_back_with_flash(
base,
"/limit-config",
FlashData::error("Rule not found"),
secure_cookies,
);
};
let Some(defaults) = current.defaults.get(&rule.id) else {
return redirect_back_with_flash(
base,
"/limit-config",
FlashData::error("This rule has no defaults to reset to"),
secure_cookies,
);
};
rule.limits = defaults.clone();
let request = LimitConfigUpdateRequest { limit_config };
let result = client.update_limit_config(&request).await;
let flash = limit_config_result(
result,
"Limit rule reset to defaults",
"Failed to reset limit rule",
);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"delete" => {
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
Some(rule_id) => rule_id,
@@ -1310,6 +1358,49 @@ mod tests {
);
}
#[test]
fn build_integrations_update_sends_the_smtp_tls_mode_only_when_the_form_has_it() {
let smtp_update = |body: &[u8]| {
build_integrations_update(&MultiValueForm::parse(body))
.integrations
.and_then(|integrations| integrations.email)
.and_then(|email| email.smtp)
.expect("smtp update")
};
let body = |update| serde_json::to_value(update).expect("serialize smtp update");
let chosen = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=none");
assert_eq!(body(chosen), serde_json::json!({"tls_mode": "none"}));
let automatic = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=");
assert_eq!(body(automatic), serde_json::json!({"tls_mode": null}));
let from_an_older_page =
smtp_update(b"integration_email_present=1&integration_smtp_secure=true");
assert_eq!(body(from_an_older_page), serde_json::json!({}));
}
#[test]
fn smtp_test_flash_says_the_settings_were_saved_either_way() {
let passed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: true,
error: None,
});
assert_eq!(
passed.message,
"Settings saved and SMTP connection verified"
);
let failed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: false,
error: Some("wrong version number".to_owned()),
});
assert_eq!(
failed.message,
"Settings saved, but the SMTP test failed: wrong version number"
);
}
#[test]
fn build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
@@ -1360,6 +1451,109 @@ mod tests {
);
}
#[test]
fn instance_config_result_surfaces_the_api_validation_message() {
let error = crate::api::client::ApiError::Http {
status: 400,
message: serde_json::json!({
"code": "INVALID_FORM_BODY",
"message": "Input Validation Error",
"errors": [{
"path": "policy.premium_mode",
"code": "X",
"message": "Disable billing before switching the premium mode to everyone"
}]
})
.to_string(),
};
assert_eq!(
instance_config_result::<()>(Err(error)).message,
"Failed to update instance config: policy.premium_mode: Disable billing before switching the premium mode to everyone"
);
}
#[test]
fn limit_config_result_surfaces_the_api_validation_message() {
let error = crate::api::client::ApiError::Http {
status: 400,
message: serde_json::json!({
"code": "INVALID_FORM_BODY",
"message": "Input Validation Error",
"errors": [{
"path": "limit_config.rules.0.limits.max_bio_length",
"code": "VALUE_MUST_BE_INTEGER_IN_RANGE",
"message": "Value must be an integer between 0 and 320."
}]
})
.to_string(),
};
assert_eq!(
limit_config_result::<()>(Err(error), "Saved", "Failed to update limit configuration")
.message,
"Failed to update limit configuration: limit_config.rules.0.limits.max_bio_length: Value must be an integer between 0 and 320."
);
}
#[test]
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
let toggle = |key: &str, is_toggle: bool| {
(
key.to_owned(),
LimitKeyMetadata {
key: key.to_owned(),
label: key.to_owned(),
description: String::new(),
category: "features".to_owned(),
scope: "user".to_owned(),
is_toggle,
unit: None,
min: None,
max: None,
},
)
};
let metadata = std::collections::BTreeMap::from([
toggle("feature_animated_avatar", true),
toggle("feature_animated_banner", true),
toggle("max_guilds", false),
]);
let limit_keys = metadata.keys().cloned().collect::<Vec<_>>();
let mut rule = LimitRule {
id: "premium".to_owned(),
filters: None,
limits: std::collections::BTreeMap::from([
("feature_animated_avatar".to_owned(), 1),
("feature_animated_banner".to_owned(), 1),
("max_guilds".to_owned(), 200),
]),
modified_fields: None,
};
let form =
MultiValueForm::parse(b"traits=premium&feature_animated_banner=1&max_guilds=250");
update_limit_rule_values(&mut rule, &form, &limit_keys, &metadata, None);
assert_eq!(
rule.limits,
std::collections::BTreeMap::from([
("feature_animated_avatar".to_owned(), 0),
("feature_animated_banner".to_owned(), 1),
("max_guilds".to_owned(), 250),
])
);
let mut custom = LimitRule {
id: "vip".to_owned(),
filters: None,
limits: std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)]),
modified_fields: None,
};
let form = MultiValueForm::parse(b"traits=vip&max_guilds=300");
update_limit_rule_values(&mut custom, &form, &limit_keys, &metadata, None);
assert_eq!(
custom.limits,
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
@@ -1823,7 +2017,13 @@ mod tests {
limits: std::collections::BTreeMap::new(),
modified_fields: None,
};
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
update_limit_rule_values(
&mut rule,
&form,
&["message_send".to_owned()],
&std::collections::BTreeMap::new(),
None,
);
assert_eq!(rule.limits.get("message_send"), Some(&1));
assert_eq!(
rule.filters.expect("filters").traits,
@@ -151,6 +151,10 @@ pub fn danger_button(label: &str) -> Markup {
button_markup(label, "submit", DANGER_BUTTON_CLASS)
}
pub fn secondary_button(label: &str) -> Markup {
button_markup(label, "submit", SECONDARY_BUTTON_CLASS)
}
pub fn form_field_group(
label: &str,
name: &str,
@@ -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());
}
}
@@ -9,7 +9,7 @@ use crate::{
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
},
config::AdminConfig,
@@ -158,6 +158,7 @@ pub fn instance_config_page(
csrf_token,
&instance_config.policy,
&instance_config.app_public.branding.premium_product_name,
instance_config.billing.enabled == Some(true),
))
},
))
@@ -199,7 +200,12 @@ pub fn instance_config_page(
"Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.",
html! {
(media_config_section(base, csrf_token, &instance_config.media))
(media_config_section(
base,
csrf_token,
&instance_config.media,
instance_config.self_hosted,
))
},
))
(config_group(
@@ -264,6 +270,7 @@ fn policy_config_section(
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
billing_enabled: bool,
) -> Markup {
section_card_with_description(
"Community & Policy",
@@ -274,7 +281,7 @@ fn policy_config_section(
div class="space-y-8" {
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(premium_mode_form(base, csrf_token, policy, premium_name, billing_enabled))
(community_creation_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
@@ -373,6 +380,7 @@ fn premium_mode_form(
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
billing_enabled: bool,
) -> Markup {
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
@@ -386,6 +394,16 @@ fn premium_mode_form(
("mirror", mirror_label.as_str()),
("everyone", everyone_label.as_str()),
], policy.premium_mode.as_str()))
p class="text-xs text-neutral-500" {
"Limits you have not edited follow the model. Limits you changed under Limit \
Config keep your values in both models."
}
@if billing_enabled && matches!(policy.premium_mode, PremiumMode::Mirror) {
p class="text-sm text-amber-700" {
"Billing is enabled. Disable it under Premium & billing before switching \
the premium model to Everyone."
}
}
(form_actions(html! {
(submit_button("Save premium model"))
}))
@@ -528,6 +546,16 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
)
}
fn smtp_tls_mode_summary(mode: &str) -> &str {
match mode {
"implicit" => "implicit TLS",
"starttls" => "required STARTTLS",
"opportunistic" => "STARTTLS when the server offers it",
"none" => "no encryption",
other => other,
}
}
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
let description = match account_identity.mode {
AccountIdentityMode::Username => {
@@ -671,15 +699,34 @@ fn integrations_config_section(
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(select_input("integration_smtp_tls_mode", "SMTP encryption", &[
("", "Automatic"),
("implicit", "Implicit TLS"),
("starttls", "STARTTLS, required"),
("opportunistic", "STARTTLS, when the server offers it"),
("none", "None"),
], integrations.email.smtp.tls_mode.as_deref().unwrap_or("")))
p class="text-xs text-neutral-500" {
"Automatic follows FLUXER_EMAIL_SMTP_TLS_MODE when it is set, then the older FLUXER_EMAIL_SMTP_SECURE, where true means implicit TLS on any port. With neither set it uses implicit TLS on port 465 and requires STARTTLS on every other port."
}
@if let Some(mode) = integrations.email.smtp.effective_tls_mode.as_deref() {
p class="text-xs text-neutral-500" {
"Outgoing email currently uses " (smtp_tls_mode_summary(mode))
@if let Some(port) = integrations.email.smtp.port { " on port " (port) }
"."
}
}
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
span { "Save and test SMTP" }
}
}
p class="text-xs text-neutral-500" {
"The test saves this form first, then connects with the saved settings, the same ones outgoing email uses."
}
}
}
@@ -739,7 +786,12 @@ fn integrations_config_section(
)
}
fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaResponse) -> Markup {
fn media_config_section(
base: &str,
csrf_token: &str,
media: &InstanceMediaResponse,
self_hosted: bool,
) -> Markup {
let decay = &media.attachment_decay;
let effective = &decay.effective;
let enabled = decay.enabled.unwrap_or(effective.enabled);
@@ -767,9 +819,14 @@ fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaRespo
.renew_window_days
.unwrap_or(effective.renew_window_days)
.to_string();
let description = if self_hosted {
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. On a self-hosted instance, changes also apply to attachments uploaded earlier, which are kept for at least the renew window before a new or shorter expiry deletes them."
} else {
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. Changes apply to attachments uploaded from then on."
};
section_card_with_description(
"Media Expiry",
"Expire eligible attachments using size-based lifetimes. This is disabled by default and applies at runtime.",
description,
html! {
form method="post" action={(base) "/instance-config?action=update_media"} {
(csrf_input(csrf_token))
@@ -893,9 +950,9 @@ fn app_public_config_section(
))
(text_input(
"app_theme_color",
"Theme Color",
"Theme Color (hex)",
app_public.branding.theme_color.as_deref().unwrap_or(""),
"#5865f2",
"#4641d9",
))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
@@ -2142,224 +2199,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("name=\"domain_migration_rollout_country_codes\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
let voice_p2p = VoiceP2pConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
max_participants: 3,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..VoiceP2pConfigResponse::default()
};
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
assert!(markup.contains("Peer-to-peer voice"));
assert!(markup.contains("action=update_voice_p2p"));
assert!(markup.contains("name=\"voice_p2p_enabled\""));
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
assert!(markup.contains("value=\"3\""));
assert!(markup.contains("min=\"2\""));
assert!(markup.contains("max=\"4\""));
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
assert!(markup.contains("value=\"SE, NO\""));
assert!(markup.contains("2 of 250 stored"));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[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<"));
}
}
@@ -8,7 +8,10 @@ use crate::{
middleware::auth::AuthContext,
templates::{
components::{
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
form::{
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, secondary_button,
submit_button,
},
page_container::{card_with_header, page_header},
tooltip,
},
@@ -81,7 +84,7 @@ fn render_limit_page(
div class="space-y-6" {
(rule_tabs(config, &rules, active_rule_id, can_update))
@if let Some(rule) = active_rule {
(rule_header(config, csrf_token, rule, can_update))
(rule_header(&config.base_path, csrf_token, rule, can_update, response.defaults.contains_key(&rule.id)))
(rule_editor(config, csrf_token, response, rule, can_update))
} @else {
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
@@ -151,11 +154,18 @@ fn rule_tabs(
}
fn rule_header(
config: &AdminConfig,
base: &str,
csrf_token: &str,
rule: &LimitRule,
can_update: bool,
has_defaults: bool,
) -> Markup {
let can_reset = can_update
&& has_defaults
&& rule
.modified_fields
.as_ref()
.is_some_and(|fields| !fields.is_empty());
card_with_header(
"",
html! {
@@ -176,11 +186,20 @@ fn rule_header(
}
}
}
@if can_update && rule.id != "default" {
form method="post"
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
(csrf_input(csrf_token))
(danger_button("Delete Rule"))
div class="flex flex-wrap gap-2" {
@if can_reset {
form method="post"
action={(base) "/limit-config?action=reset&rule=" (rule.id)} {
(csrf_input(csrf_token))
(secondary_button("Reset to Defaults"))
}
}
@if can_update && rule.id != "default" {
form method="post"
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
(csrf_input(csrf_token))
(danger_button("Delete Rule"))
}
}
}
}
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"));
}
}
@@ -112,9 +112,9 @@ fn render_user_detail(
@if let Some(banner) = banner_url {
div class="mb-4 space-y-2" {
a href=(banner) target="_blank" rel="noreferrer noopener"
class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
img src=(banner) alt={(user.username) "'s banner"}
class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48"
class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
loading="lazy";
}
@if let Some(hash) = &user.banner {
@@ -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"));
}
}
@@ -550,6 +550,10 @@ fn traits_form(
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
(custom_traits.join("\n"))
}
p class="text-neutral-500 text-xs" {
"The premium trait follows each member's subscription or gift and cannot be \
assigned here."
}
}
(form_actions(html! {
(submit_button("Save Traits"))
@@ -584,84 +588,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());
}
}
+2 -1
View File
@@ -500,7 +500,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"port": 587,
"username": "fluxer",
"password_set": true,
"secure": true
"tls_mode": null,
"effective_tls_mode": "starttls"
},
"disable_new_ip_authorization": false,
"effective_disable_new_ip_authorization": false
+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
View File
@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"@fluxer/i18n": "workspace:*",
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:",
@@ -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;
}
@@ -3,42 +3,26 @@
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
const {createTransport} = vi.hoisted(() => ({createTransport: 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'});
}
vi.mock('nodemailer', () => ({default: {createTransport}}));
describe('SmtpEmailProvider', () => {
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue({});
createTransport.mockReset();
createTransport.mockReturnValue({sendMail: vi.fn(), verify: vi.fn()});
});
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');
it.each([
{port: 465, tlsMode: undefined, secure: true, requireTLS: false, ignoreTLS: false},
{port: 587, tlsMode: undefined, secure: false, requireTLS: true, ignoreTLS: false},
{port: 587, tlsMode: null, secure: false, requireTLS: true, ignoreTLS: false},
{port: 587, tlsMode: 'implicit', secure: true, requireTLS: false, ignoreTLS: false},
{port: 465, tlsMode: 'starttls', secure: false, requireTLS: true, ignoreTLS: false},
{port: 25, tlsMode: 'opportunistic', secure: false, requireTLS: false, ignoreTLS: false},
{port: 587, tlsMode: 'none', secure: false, requireTLS: false, ignoreTLS: true},
] as const)('maps TLS mode $tlsMode on port $port to nodemailer options', ({port, tlsMode, ...expected}) => {
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
});
});
Loaded 100 of 2341 files, more files were not shown because too many files have changed in this diff. Show more