Compare commits

...
51 Commits
Author SHA1 Message Date
Aru Moon 8a5d4a8bdb fix(app): send current client locale on login/registration (#3412) 2026-10-12 14:26:11 +02:00
Hampus 0ba15ee46a fix(api): delete guilds in a background job (#3417) 2026-10-12 14:11:06 +02:00
Hampus 85d9ef7386 fix(app): hash emitted license asset names (#3416) 2026-10-12 13:58:28 +02:00
Hampus f8d85f0e5e test(cache): freeze clock for ttl resolution (#3415) 2026-10-12 13:37:56 +02:00
Hampus 7f674474b5 fix(app): include record utils in image builds (#3414) 2026-10-12 13:29:06 +02:00
Hampus c9fdf8fead chore(repo): prune dead code and duplicate abstractions (#3413) 2026-10-12 13:09:42 +02:00
Hampus c17091768c feat(app): pick screen share bitrate up to the limit (#3411) 2026-10-12 02:17:53 +02:00
Hampus e0640d68ff feat(limits): add max_screen_share_bitrate limit (#3410) 2026-10-12 02:16:40 +02:00
Hampus 3292dc5f89 test(media-proxy): drop animated jxl fixture case (#3409) 2026-10-12 00:17:29 +02:00
Hampus 86e29b61e3 fix(media-proxy): serve large simple jxl, fit full size (#3408) 2026-10-12 00:05:14 +02:00
Qsie f8d3c70a91 chore(svc): replace deprecated fetch_update with try_update (#3407)
Signed-off-by: Qsie <[email protected]>
2026-10-11 23:30:31 +02:00
Hampus 74c6fad260 fix(billing): stack gifts from paid period end after trial ends (#3406) 2026-10-11 20:08:19 +02:00
Hampus bcb30eea88 fix(voice): one-way audio and stream loss in p2p calls (#3405) 2026-10-11 19:27:24 +02:00
Hampus d59ddc2214 fix(app): wait for the session before redirecting from /app (#3404) 2026-10-11 18:47:50 +02:00
Hampus e87b593f46 fix(media-proxy): fail slow external fetches sooner (#3403) 2026-10-11 18:47:11 +02:00
Hampus 5a2d81ebbb fix(media-proxy): bound animated transforms by output pixels (#3402) 2026-10-11 18:46:34 +02:00
Hampus 50c28aa8fa fix(admin): use instance branding on self-hosted (#3401) 2026-10-11 15:57:12 +02:00
Hampus af7724dbf6 fix(push): drop Fluxer badge on self-hosted instances (#3400) 2026-10-11 15:56:53 +02:00
Hampus be314788b3 fix(app): pixel-align mobile status indicator notch (#3399) 2026-10-11 15:56:15 +02:00
Hampus 5b3f69bf27 fix(app): only remember a language the user picked (#3398) 2026-10-11 15:55:55 +02:00
Hampus d7aa6ec2aa fix(app): use instance branding in self-hosted copy (#3397) 2026-10-11 15:55:15 +02:00
Hampus 20e3474393 fix(app-proxy): serve instance branding before boot (#3396) 2026-10-11 15:54:40 +02:00
Hampus 3694300d86 fix(api): show premium badge for premium override (#3395) 2026-10-11 14:55:43 +02:00
Hampus 54ec74e4f9 fix(app): load quick css from the account scope after sign-in (#3394) 2026-10-11 14:49:15 +02:00
Hampus aa3b058ce6 fix(media-proxy): serve originals and speed up slow transforms (#3393) 2026-10-11 14:41:01 +02:00
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
Hampus 70fd9e6331 refactor(api): drop ignored location param from thread routes (#3371) 2026-10-10 17:57:15 +02:00
Hampus c14b7d4cb3 fix(members): show mobile badges and fresh profiles in lists (#3370) 2026-10-10 17:00:30 +02:00
Hampus 0f0ff5f3df feat(schema): add swipe right to left action setting (#3369) 2026-10-10 16:36:13 +02:00
Hampus b4c614d21a fix(threads): show presence in thread member list (#3368) 2026-10-10 15:58:59 +02:00
4401 changed files with 360710 additions and 8017081 deletions

No files matched your search

-19
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,21 +160,11 @@ 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
volume:
nocopy: true
- type: volume
source: fluxer-api-locale-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/locale/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-media-proxy-utils-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/media_proxy_utils/node_modules
@@ -355,7 +339,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,9 +354,7 @@ 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:
fluxer-api-mime-utils-node-modules:
fluxer-api-nats-node-modules:
+3 -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/
@@ -45,9 +48,6 @@
/packages/markdown_parser/rust/fuzz/artifacts/
/packages/markdown_parser/rust/fuzz/corpus/
/fluxer_media_proxy/.benchmark-cache/
/fluxer_media_proxy/bench-results/
/app-dist-output/
/artifacts/
/desktop-shared-assets/
+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
+38
View File
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: App Wasm cache
description: Restore or save the shared application Wasm artifacts
inputs:
mode:
description: Cache operation (restore or save)
required: true
outputs:
cache-hit:
description: Whether the restore found the exact artifact key
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Resolve artifact key
id: key
if: always()
shell: bash
run: echo "key=app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', 'fluxer_app/rust/libfluxwebp/simd/**', 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', 'packages/markdown_parser/rust/src/**') }}" >> "$GITHUB_OUTPUT"
- name: Restore artifacts
id: cache
if: inputs.mode == 'restore'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: ${{ steps.key.outputs.key }}
- name: Save artifacts
if: always() && inputs.mode == 'save'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: ${{ steps.key.outputs.key }}
+29
View File
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: CI helper
description: Restore or build the shared Rust CI helper
inputs:
save:
description: Save a missing helper cache on main
required: false
default: 'false'
runs:
using: composite
steps:
- name: Restore ci helper
id: cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
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/**') }}
- name: Build ci helper
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: cargo build --locked --package fluxer-ci
- name: Save ci helper
if: inputs.save == 'true' && github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: target/debug/fluxer-ci
key: ${{ steps.cache.outputs.cache-primary-key }}
-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/**/*
+2 -18
View File
@@ -12,26 +12,11 @@ on:
description: "Path to the Dockerfile to build"
type: string
required: true
context:
description: "Docker build context"
type: string
required: false
default: "."
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
moving-tags:
description: "Comma-separated moving tags to repoint at this build"
type: string
required: false
default: "v1,latest"
extra-build-args:
description: "Additional Docker build args, one KEY=VALUE entry per line"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
@@ -115,7 +100,7 @@ jobs:
password: ${{ github.token }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: ${{ inputs.context }}
context: .
file: ${{ inputs.dockerfile }}
push: true
provenance: mode=min
@@ -125,7 +110,6 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
${{ inputs.extra-build-args }}
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
@@ -190,7 +174,7 @@ jobs:
- name: Advance moving image tags
env:
MOVING_TAGS: ${{ inputs.moving-tags }}
MOVING_TAGS: v1,latest
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
-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
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
--step install_dependencies
- name: Compile translated catalogs
run: pnpm i18n:weblate-compile
run: pnpm i18n:compile
- name: Format generated catalogs
run: pnpm biome format --write package.json packages/i18n/package.json packages/i18n/scripts/SyncStaticI18nCatalogs.ts packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
+25 -122
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'
@@ -36,27 +35,10 @@ jobs:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
id: ci-helper
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
- name: Set up ci helper
uses: ./.github/actions/ci-helper
with:
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/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
run: cargo build --locked --package fluxer-ci
- name: Save ci helper
if: github.ref == 'refs/heads/main' && steps.ci-helper.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
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/**') }}
save: 'true'
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -91,18 +73,8 @@ jobs:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
id: ci-helper
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
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/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
run: cargo build --locked --package fluxer-ci
- name: Set up ci helper
uses: ./.github/actions/ci-helper
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -119,22 +91,9 @@ jobs:
- name: Restore fluxer_app wasm artifacts
id: app-wasm
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
uses: ./.github/actions/app-wasm-cache
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
mode: restore
- name: Run tests
run: |
@@ -142,22 +101,9 @@ jobs:
- name: Save fluxer_app wasm artifacts
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
uses: ./.github/actions/app-wasm-cache
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
mode: save
rust:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -277,6 +223,9 @@ jobs:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run tests (libfluxcore)
run: cargo test --locked --manifest-path fluxer_app/rust/libfluxcore/Cargo.toml
- name: Run desktop native workspace tests on Linux
run: tools/ci/check-desktop-native-workspaces.sh test
@@ -302,18 +251,8 @@ jobs:
fluxer_gateway/native/push_markdown_plaintext_nif -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Restore ci helper
id: ci-helper
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
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/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
run: cargo build --locked --package fluxer-ci
- name: Set up ci helper
uses: ./.github/actions/ci-helper
- name: Set up Erlang
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
@@ -383,18 +322,8 @@ jobs:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
id: ci-helper
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
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/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
run: cargo build --locked --package fluxer-ci
- name: Set up ci helper
uses: ./.github/actions/ci-helper
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -411,22 +340,9 @@ jobs:
- name: Restore fluxer_app wasm artifacts
id: app-wasm
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
uses: ./.github/actions/app-wasm-cache
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
mode: restore
- name: Run knip
run: |
@@ -434,22 +350,9 @@ jobs:
- name: Save fluxer_app wasm artifacts
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
uses: ./.github/actions/app-wasm-cache
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
mode: save
lint:
runs-on: ubuntu-24.04
@@ -501,14 +404,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 -5
View File
@@ -35,15 +35,13 @@
/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/
/fluxer_media_proxy/.benchmark-cache/
/fluxer_media_proxy/bench-results/
/app-dist-output/
/artifacts/
/desktop-shared-assets/
Generated
+18 -89
View File
@@ -1157,8 +1157,6 @@ dependencies = [
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
@@ -1185,16 +1183,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
@@ -1736,8 +1724,9 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"fluxer_common",
"hex",
"hyper",
"hyper-util",
"libc",
@@ -1781,7 +1770,6 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tempfile",
]
[[package]]
@@ -1794,7 +1782,6 @@ dependencies = [
"bytes",
"cc",
"clap",
"criterion",
"flate2",
"fluxer_common",
"futures-util",
@@ -1805,6 +1792,7 @@ dependencies = [
"http-body-util",
"hyper",
"libc",
"memmap2",
"moka",
"parking_lot",
"percent-encoding",
@@ -1836,7 +1824,6 @@ dependencies = [
"anyhow",
"base64 0.23.1",
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
@@ -1861,6 +1848,7 @@ dependencies = [
"base64 0.23.1",
"clap",
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"p256",
@@ -1901,6 +1889,7 @@ dependencies = [
"bytes",
"chrono",
"deadpool-postgres",
"fluxer_common",
"futures",
"libc",
"rmp-serde",
@@ -2017,24 +2006,21 @@ 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",
"http 1.5.0",
"libc",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
]
[[package]]
@@ -2637,12 +2623,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 +2871,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 +2887,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"
@@ -3331,34 +3308,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -3728,26 +3677,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
-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
-5
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
@@ -71,10 +70,6 @@ workloads:
image:
name: fluxer-app-proxy-self-hosted
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.
+8 -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,19 @@ 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_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
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:-}
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
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:-}
+69 -19
View File
@@ -3074,7 +3074,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
"description": "Permanently deletes a guild. The guild becomes unavailable at once, and a background job deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -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"}}}
}
}
@@ -11107,7 +11107,7 @@
"category": {"type": "string"},
"scope": {"type": "string"},
"isToggle": {"type": "boolean"},
"unit": {"type": "string", "enum": ["bytes", "count"]},
"unit": {"type": "string", "enum": ["bytes", "count", "bps"]},
"min": {"type": "number"},
"max": {"type": "number"}
},
@@ -11154,6 +11154,7 @@
"max_private_channels_per_user",
"max_reactions_per_message",
"max_relationships",
"max_screen_share_bitrate",
"max_users_per_message_reaction",
"max_voice_message_duration",
"max_webhooks_per_channel",
@@ -11523,9 +11524,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 +11814,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 +11906,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 +11964,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"}
@@ -17218,7 +17272,6 @@
"ACCOUNT_TOO_NEW_FOR_GUILD",
"ACLS_MUST_BE_NON_EMPTY",
"ADMIN_API_KEY_NOT_FOUND",
"APPLICATION_NOT_OWNED",
"ALREADY_FRIENDS",
"BOTS_CANNOT_CREATE_GUILDS",
"BOTS_CANNOT_SEND_FRIEND_REQUESTS",
@@ -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",
@@ -17515,7 +17567,6 @@
"Your account is too new to send messages in this community.",
"ACLs must be non-empty.",
"Admin API key wasn't found.",
"You don't own this application.",
"You're already friends with this user.",
"Bots can't create communities.",
"Bots can't send friend requests.",
@@ -17541,7 +17592,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.",
+64
View File
@@ -160,6 +160,70 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
},
];
pub const DELETION_REASONS: &[(&str, &str)] = &[
("1", "User requested"),
("2", "Other"),
("3", "Spam"),
("4", "Cheating or exploitation"),
("5", "Coordinated raiding or manipulation"),
("6", "Automation or self-bot usage"),
("7", "Nonconsensual sexual content"),
("8", "Scam or social engineering"),
("9", "Child sexual content"),
("10", "Privacy violation or doxxing"),
("11", "Harassment or bullying"),
("12", "Payment fraud"),
("13", "Child safety violation"),
("14", "Billing dispute or abuse"),
("15", "Unsolicited explicit content"),
("16", "Graphic violence"),
("17", "Ban evasion"),
("18", "Token or credential scam"),
("19", "Inactivity"),
("20", "Hate speech or extremist content"),
("21", "Malicious links or malware distribution"),
("22", "Impersonation or fake identity"),
];
pub const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
"RAID_DETECTED",
"TEXT_CHANNEL_FLEXIBLE_NAMES",
"HIDE_OWNER_CROWN",
"MORE_EMOJI",
"MORE_STICKERS",
"UNLIMITED_EMOJI",
"UNLIMITED_STICKERS",
"EXPRESSION_PURGE_ALLOWED",
"VANITY_URL",
"DISCOVERABLE",
"PARTNERED",
"VERIFIED",
"VIP_VOICE",
"VOICE_E2EE",
"UNAVAILABLE_FOR_EVERYONE",
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
"UNAVAILABLE_HIDDEN",
"VISIONARY",
"LARGE_GUILD_OVERRIDE",
"VERY_LARGE_GUILD",
"ANNOUNCEMENT_CHANNELS_DISABLED",
];
pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
#[cfg(test)]
mod tests {
use super::USER_FLAGS;
+5
View File
@@ -12,6 +12,11 @@ pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
"Community creation policy",
)),
}),
"max_screen_share_bitrate" => Some(Hint {
name: Some("Max Screen Share Bitrate"),
body: "In bits per second, so 9000000 is 9 Mbps. Raising it also raises the automatic bitrate of every screen share quality. Accounts with Higher Video Quality can pick their own bitrate up to this value.",
link: None,
}),
_ => None,
}
}
+5 -2
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use super::client::{AdminApiClient, ApiResult};
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
use serde::Serialize;
@@ -43,12 +45,13 @@ impl AdminApiClient {
new_owner_id: &str,
) -> ApiResult<ApplicationUpdateResponse> {
let body = TransferApplicationOwnershipRequest { new_owner_id };
self.patch_typed_with_reason(
self.send(
Method::PATCH,
&format!(
"/admin/applications/{}",
urlencoding::encode(application_id)
),
&body,
Some(&body),
None,
)
.await
+7 -14
View File
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::{snowflake, types as generated_types};
use reqwest::Method;
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<()> {
@@ -205,9 +207,10 @@ impl AdminApiClient {
let body = generated_types::BulkBanFileShasRequest {
sha256_list: sha256_list.to_vec(),
};
self.put_typed_with_reason(
self.send(
Method::PUT,
"/admin/blocklists/file-sha/entries",
&body,
Some(&body),
audit_log_reason,
)
.await
@@ -249,16 +252,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,
+39 -28
View File
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::{guild_features, user_flags};
use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -18,8 +22,13 @@ impl AdminApiClient {
remove_flags: user_flags(remove_flags)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
self.send(
Method::POST,
"/admin/bulk-jobs",
Some(&body),
audit_log_reason,
)
.await
}
pub async fn bulk_update_guild_features(
@@ -34,8 +43,13 @@ impl AdminApiClient {
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
self.send(
Method::POST,
"/admin/bulk-jobs",
Some(&body),
audit_log_reason,
)
.await
}
pub async fn bulk_add_guild_members(
@@ -48,8 +62,13 @@ impl AdminApiClient {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
self.send(
Method::POST,
"/admin/bulk-jobs",
Some(&body),
audit_log_reason,
)
.await
}
pub async fn bulk_delete_user_messages(
@@ -60,8 +79,13 @@ impl AdminApiClient {
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
self.send(
Method::POST,
"/admin/bulk-jobs",
Some(&body),
audit_log_reason,
)
.await
}
pub async fn bulk_schedule_user_deletion(
@@ -89,29 +113,16 @@ impl AdminApiClient {
.map_err(ApiError::Parse)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
self.send(
Method::POST,
"/admin/bulk-jobs",
Some(&body),
audit_log_reason,
)
.await
}
}
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values.iter().map(|value| snowflake(value)).collect()
}
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
.cloned()
.map(generated_types::GuildFeatureSchema::from)
.collect()
}
+17 -146
View File
@@ -49,6 +49,15 @@ impl AdminApiClient {
Self { generated }
}
pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self {
let generated = GeneratedClient::new_with_client(
&config.api_endpoint,
http_client.clone(),
build_proxy_client_ip_headers(config),
);
Self { generated }
}
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let mut url = format!("{}{}", self.generated.baseurl(), path);
let query = query_params
@@ -127,170 +136,32 @@ impl AdminApiClient {
Self::parse_response(response).await
}
pub async fn post<T: DeserializeOwned>(
pub async fn send<T, B>(
&self,
method: Method,
path: &str,
body: Option<&serde_json::Value>,
) -> ApiResult<T> {
self.post_with_reason(path, body, None).await
}
pub async fn post_typed<T, B>(&self, path: &str, body: &B) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
self.post_typed_with_reason(path, body, None).await
}
pub async fn post_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
body: Option<&B>,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let response = self
.send_json(Method::POST, path, Some(body), audit_log_reason)
.await?;
let response = self.send_json(method, path, body, audit_log_reason).await?;
Self::parse_response(response).await
}
pub async fn post_with_reason<T: DeserializeOwned>(
pub async fn send_void<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
self.post_void_with_reason(path, body, None).await
}
pub async fn post_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
body: Option<&B>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
let response = self.send_json(method, path, body, audit_log_reason).await?;
Self::parse_void_response(response).await
}
pub async fn patch<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> ApiResult<T> {
self.patch_with_reason(path, body, None).await
}
pub async fn patch_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let response = self
.send_json(Method::PATCH, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn patch_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let response = self
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn put_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn put_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let response = self
.send_json(Method::PUT, path, Some(body), audit_log_reason)
.await?;
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,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
pub async fn delete_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
Self::check_response_status(response).await.map(drop)
}
+2 -4
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::float_to_u64;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -144,10 +146,6 @@ fn listed_guild(
})
}
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
}
fn float_to_i32(value: f64, field: &str) -> ApiResult<i32> {
let parsed = crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)?;
i32::try_from(parsed).map_err(|_| ApiError::Parse(format!("{field} is out of range: {value}")))
+30 -86
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use self::types as generated_types;
use crate::api::client::{ApiError, ApiResult};
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
use reqwest::header::HeaderMap;
@@ -61,94 +64,35 @@ pub(crate) fn deletion_reason_code(
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
}
pub(crate) fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
pub(crate) fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
.cloned()
.map(generated_types::GuildFeatureSchema::from)
.collect()
}
pub(crate) fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
pub(crate) fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
}
#[cfg(test)]
mod tests {
use super::{number_to_u64, types::*};
#[test]
fn deserialize_admin_users_me() {
let json = serde_json::json!({
"user": {
"id": "123456789012345678",
"username": "testadmin",
"discriminator": 1,
"global_name": "Test Admin",
"bot": false,
"system": false,
"flags": "131141",
"premium_flags": 0,
"avatar": "abc123def456",
"banner": null,
"bio": "Hello world",
"pronouns": null,
"accent_color": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"date_of_birth": "2000-01-15",
"locale": "en-US",
"premium_type": 2,
"premium_since": "2024-01-01T00:00:00.000Z",
"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,
"acls": ["MANAGE_USERS", "VIEW_AUDIT_LOG"],
"traits": ["STAFF"],
"has_totp": true,
"authenticator_types": [0],
"last_active_at": "2026-05-26T12:00:00.000Z",
"last_active_ip": "203.0.113.1",
"last_active_ip_reverse": "1.113.0.203.in-addr.arpa",
"last_active_location": "New York, US"
}
});
let response: AdminUsersMeResponse =
serde_json::from_value(json).expect("failed to deserialize AdminUsersMeResponse");
assert_eq!(response.user.id.to_string(), "123456789012345678");
assert_eq!(response.user.username, "testadmin");
assert!(!response.user.bot);
assert!(response.user.email_verified);
assert_eq!(response.user.acls.len(), 2);
assert_eq!(response.user.global_name, Some("Test Admin".to_string()));
assert!(response.user.banner.is_none());
assert!(response.user.has_totp);
}
#[test]
fn deserialize_search_guilds_response() {
let json = serde_json::json!({
"guilds": [
{
"id": "987654321098765432",
"name": "Test Guild",
"icon": "def456abc123",
"banner": null,
"owner_id": "123456789012345678",
"owner_username": "guildowner",
"owner_global_name": "Guild Owner",
"owner_discriminator": "0001",
"member_count": 42,
"features": ["COMMUNITY", "NEWS"]
}
],
"total": 1
});
let response: SearchGuildsResponse =
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
assert_eq!(response.total, 1.0);
assert_eq!(response.guilds.len(), 1);
assert_eq!(response.guilds[0].name, "Test Guild");
assert_eq!(*response.guilds[0].member_count, 42);
}
use super::number_to_u64;
#[test]
fn reject_lossy_generated_number_count() {
+23 -67
View File
@@ -1,14 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::guild_features;
use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use serde::Deserialize;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::reports::SearchReportsParams;
use super::types::{
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
SuccessResponse,
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, ListGuildMembersResponse,
LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse, SuccessResponse,
};
impl AdminApiClient {
@@ -53,18 +56,17 @@ impl AdminApiClient {
guild_id: &str,
add_features: &[String],
remove_features: &[String],
) -> ApiResult<GuildUpdateResponse> {
) -> ApiResult<()> {
let body = generated_types::UpdateGuildRequest {
add_features: guild_features(add_features),
remove_features: guild_features(remove_features),
..Default::default()
};
let response = self
.generated()
self.generated()
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
Ok(())
}
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
@@ -80,17 +82,16 @@ impl AdminApiClient {
&self,
guild_id: &str,
new_owner_id: &str,
) -> ApiResult<GuildUpdateResponse> {
) -> ApiResult<()> {
let body = generated_types::UpdateGuildRequest {
new_owner_id: Some(snowflake(new_owner_id)),
..Default::default()
};
let response = self
.generated()
self.generated()
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
Ok(())
}
pub async fn list_guild_members(
@@ -177,44 +178,37 @@ impl AdminApiClient {
&self,
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<GuildUpdateResponse> {
) -> ApiResult<()> {
let body = guild_settings_request(settings)?;
let response = self
.generated()
self.generated()
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
guild_update_response(response.into_inner())
Ok(())
}
pub async fn update_guild_name(
&self,
guild_id: &str,
name: &str,
) -> ApiResult<GuildUpdateResponse> {
pub async fn update_guild_name(&self, guild_id: &str, name: &str) -> ApiResult<()> {
let body = generated_types::UpdateGuildRequest {
name: Some(name.to_owned()),
..Default::default()
};
let response = self
.generated()
self.generated()
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
Ok(())
}
pub async fn update_guild_vanity(
&self,
guild_id: &str,
vanity: Option<&str>,
) -> ApiResult<GuildUpdateResponse> {
pub async fn update_guild_vanity(&self, guild_id: &str, vanity: Option<&str>) -> ApiResult<()> {
let body = serde_json::json!({"vanity_url_code": vanity});
self.patch(
self.send::<serde::de::IgnoredAny, _>(
Method::PATCH,
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
Some(&body),
None,
)
.await
.map(drop)
}
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
@@ -316,36 +310,6 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
})
}
fn guild_update_response(
response: generated_types::GuildUpdateResponse,
) -> ApiResult<GuildUpdateResponse> {
let guild = response.guild;
Ok(GuildUpdateResponse {
guild: GuildInfo {
id: String::from(guild.id),
name: String::from(guild.name),
icon: guild.icon.map(String::from),
banner: guild.banner.map(String::from),
owner_id: String::from(guild.owner_id),
owner_username: None,
owner_global_name: None,
owner_discriminator: None,
member_count: crate::api::generated::i64_to_u64(
i64::from(i32::from(guild.member_count)),
"member_count",
)
.map_err(ApiError::Parse)?,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: None,
content_warning_level: None,
content_warning_text: None,
description: None,
vanity_url_code: None,
},
})
}
fn guild_settings_request(
settings: &serde_json::Value,
) -> ApiResult<generated_types::UpdateGuildRequest> {
@@ -365,14 +329,6 @@ fn guild_settings_request(
})
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
.cloned()
.map(generated_types::GuildFeatureSchema::from)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+24 -11
View File
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use super::client::{AdminApiClient, ApiResult};
use super::types::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -30,33 +32,43 @@ impl AdminApiClient {
&self,
update: &InstanceConfigUpdateRequest,
) -> ApiResult<InstanceConfigResponse> {
self.patch_typed_with_reason("/admin/instance/config", update, None)
self.send(Method::PATCH, "/admin/instance/config", Some(update), None)
.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)
.await
self.send(
Method::POST,
"/admin/instance/config/smtp-tests",
Some(&serde_json::json!({})),
None,
)
.await
}
pub async fn create_registration_url(
&self,
request: &CreateRegistrationUrlRequest,
) -> ApiResult<CreateRegistrationUrlResponse> {
self.post_typed("/admin/instance/registration-urls", request)
.await
self.send(
Method::POST,
"/admin/instance/registration-urls",
Some(request),
None,
)
.await
}
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
self.delete_with_reason(
self.send(
Method::DELETE,
&format!(
"/admin/instance/registration-urls/{}",
urlencoding::encode(id)
),
None,
None::<&serde_json::Value>,
None,
)
.await
@@ -82,7 +94,8 @@ impl AdminApiClient {
status: &str,
) -> ApiResult<InstanceConfigResponse> {
let body = serde_json::json!({"status": status});
self.patch_with_reason(
self.send(
Method::PATCH,
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
+5 -2
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
@@ -52,9 +54,10 @@ impl AdminApiClient {
job_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<CancelJobResponse> {
self.put_with_reason(
self.send(
Method::PUT,
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
None,
None::<&serde_json::Value>,
audit_log_reason,
)
.await
+3 -1
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use super::client::{AdminApiClient, ApiResult};
use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
@@ -12,7 +14,7 @@ impl AdminApiClient {
&self,
request: &LimitConfigUpdateRequest,
) -> ApiResult<LimitConfigResponse> {
self.put_typed_with_reason("/admin/limit-config", request, None)
self.send(Method::PUT, "/admin/limit-config", Some(request), None)
.await
}
}
+5 -2
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -16,13 +18,14 @@ impl AdminApiClient {
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let _: serde_json::Value = self
.delete_with_reason(
.send(
Method::DELETE,
&format!(
"/admin/channels/{}/messages/{}",
urlencoding::encode(channel_id),
urlencoding::encode(message_id)
),
None,
None::<&serde_json::Value>,
audit_log_reason,
)
.await?;
+9 -4
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -51,7 +53,8 @@ impl AdminApiClient {
body["public_comment"] = serde_json::Value::from(public_comment);
}
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
self.patch_with_reason(
self.send(
Method::PATCH,
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
audit_log_reason,
@@ -69,7 +72,8 @@ impl AdminApiClient {
"legal_hold_until": legal_hold_until,
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
});
self.post_with_reason(
self.send(
Method::POST,
&format!(
"/admin/reports/{}/legal-hold",
urlencoding::encode(report_id)
@@ -85,9 +89,10 @@ impl AdminApiClient {
report_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_void_with_reason(
self.send_void(
Method::DELETE,
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
None,
None::<&serde_json::Value>,
audit_log_reason,
)
.await
+2 -4
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::float_to_u64;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -74,10 +76,6 @@ fn index_refresh_status(
}
}
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
}
#[cfg(test)]
mod tests {
use super::*;
-10
View File
@@ -61,11 +61,6 @@ pub struct LookupGuildResponse {
pub guild: Option<GuildDetailInfo>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GuildUpdateResponse {
pub guild: GuildInfo,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ListUserGuildsResponse {
pub guilds: Vec<GuildInfo>,
@@ -284,11 +279,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,
+76 -17
View File
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub product_name: Option<String>,
pub icon_url: Option<String>,
pub favicon_url: Option<String>,
pub premium_product_name: Option<String>,
}
@@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures {
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
non_blank(self.app_public.branding.premium_product_name.as_deref())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
pub product_name: Option<String>,
pub favicon_url: Option<String>,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
let branding = &discovery.app_public.branding;
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
product_name: non_blank(branding.product_name.as_deref()).map(str::to_owned),
favicon_url: favicon_url(
branding.favicon_url.as_deref(),
branding.icon_url.as_deref(),
),
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
let branding = &config.app_public.branding;
Self {
product_name: non_blank(Some(&branding.product_name)).map(str::to_owned),
favicon_url: favicon_url(
branding.favicon_url.as_deref(),
branding.icon_url.as_deref(),
),
..Self::from_config_parts(
config.self_hosted,
&branding.premium_product_name,
config.policy.premium_mode,
)
}
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
name: non_blank(Some(name)).map(str::to_owned),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
..Self::default()
}
}
}
fn non_blank(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn favicon_url(favicon_url: Option<&str>, icon_url: Option<&str>) -> Option<String> {
non_blank(favicon_url)
.or_else(|| non_blank(icon_url))
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -304,18 +328,52 @@ mod tests {
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
premium_enabled: true,
product_name: Some("Example".to_owned()),
favicon_url: None,
}
);
}
#[test]
fn instance_branding_prefers_the_favicon_and_falls_back_to_the_icon() {
let discovery = |branding| -> InstancePremiumDiscovery {
serde_json::from_value(json!({"app_public": {"branding": branding}}))
.expect("discovery")
};
let both = discovery(json!({
"favicon_url": "https://media.example/favicon.png",
"icon_url": "https://media.example/icon.png"
}));
assert_eq!(
PremiumBranding::from_discovery(&both)
.favicon_url
.as_deref(),
Some("https://media.example/favicon.png")
);
let icon_only =
discovery(json!({"favicon_url": " ", "icon_url": "https://media.example/icon.png"}));
assert_eq!(
PremiumBranding::from_discovery(&icon_only)
.favicon_url
.as_deref(),
Some("https://media.example/icon.png")
);
let neither = discovery(json!({"product_name": " "}));
assert_eq!(
PremiumBranding::from_discovery(&neither),
PremiumBranding::default()
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
premium_enabled: false,
..PremiumBranding::default()
}
);
assert!(
@@ -325,7 +383,8 @@ mod tests {
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
premium_enabled: true,
..PremiumBranding::default()
}
);
}
+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>,
}
+12 -28
View File
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::{bool_param, user_flags};
use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
@@ -41,16 +45,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![]);
@@ -377,9 +371,10 @@ impl AdminApiClient {
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
.put_typed_with_reason(
.send(
Method::PUT,
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
&body,
Some(&body),
private_reason,
)
.await?;
@@ -449,7 +444,8 @@ impl AdminApiClient {
"notify_user": notify_user,
});
let resp: UserMutationResponse = self
.delete_with_reason(
.send(
Method::DELETE,
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
Some(&body),
audit_log_reason,
@@ -468,9 +464,11 @@ impl AdminApiClient {
"ban_audit_log_id": ban_audit_log_id,
"note": note,
});
self.post_void(
self.send_void(
Method::POST,
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
Some(&body),
None,
)
.await
}
@@ -599,20 +597,6 @@ fn nonempty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.is_empty())
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
fn premium_flags(values: &[i32]) -> Vec<generated_types::PremiumFlags> {
values
.iter()
+10 -21
View File
@@ -1,12 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::bool_param;
use reqwest::Method;
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 {
@@ -57,7 +61,8 @@ impl AdminApiClient {
let region_id = required_field(params, "id")?;
let body = voice_request_body(params, &["id"])?;
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
self.patch_with_reason(
self.send(
Method::PATCH,
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(&body),
None,
@@ -83,19 +88,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,
@@ -123,7 +115,8 @@ impl AdminApiClient {
paired_coordinates(params)?;
let body = voice_request_body(params, &["region_id", "server_id"])?;
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
self.patch_with_reason(
self.send(
Method::PATCH,
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
@@ -149,10 +142,6 @@ impl AdminApiClient {
}
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
+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 -25
View File
@@ -22,33 +22,9 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(%addr, "starting Fluxer admin service");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.with_graceful_shutdown(fluxer_common::shutdown::wait_for_shutdown())
.await
.context("admin server exited unexpectedly")?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
+15 -52
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::AdminUser,
api::types::{AdminUser, PremiumBranding},
middleware::flash,
session::{self, Session},
state::AppState,
@@ -16,6 +16,7 @@ use axum::{
pub struct AuthContext {
pub session: Session,
pub admin_user: Option<AdminUser>,
pub branding: Option<PremiumBranding>,
}
pub async fn require_auth(
@@ -28,27 +29,19 @@ pub async fn require_auth(
let Some(session) = session else {
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
};
let admin_result = fetch_admin_user(state.http_client(), config, &session).await;
match admin_result {
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(admin_user) => {
let auth_context = AuthContext {
session,
admin_user: Some(*admin_user),
};
request.extensions_mut().insert(auth_context);
}
AdminFetchResult::None => {
let auth_context = AuthContext {
session,
admin_user: None,
};
request.extensions_mut().insert(auth_context);
}
}
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
AdminFetchResult::None => None,
};
request.extensions_mut().insert(AuthContext {
session,
admin_user,
branding: state.self_hosted_branding().await,
});
let had_flash = flash::extract_flash(&request);
if let Some(ref fd) = had_flash {
@@ -64,42 +57,12 @@ pub async fn require_auth(
response
}
fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str {
if config.base_path.is_empty() {
"/"
} else {
config.base_path.as_str()
}
}
fn clear_session_and_redirect(url: &str, config: &crate::config::AdminConfig) -> Response {
let mut response = Redirect::to(url).into_response();
let admin_path = admin_cookie_path(config);
for path in [admin_path, "/"] {
if path != admin_path && admin_path == "/" {
continue;
}
let clear = format!(
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
crate::session::SESSION_COOKIE_NAME,
path
);
if let Ok(v) = axum::http::HeaderValue::from_str(&clear) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, v);
}
let legacy_clear = format!(
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
crate::session::LEGACY_SESSION_COOKIE_NAME,
path
);
if let Ok(v) = axum::http::HeaderValue::from_str(&legacy_clear) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, v);
}
}
session::append_cookies(
&mut response,
&session::clear_cookie_values(session::SESSION_COOKIE_NAME, config),
);
response
}
+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
));
}
}
+9 -44
View File
@@ -4,14 +4,17 @@ use crate::{
api::client::AdminApiClient,
config::AdminConfig,
oauth2,
session::{self, LEGACY_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_MAX_AGE},
session::{
self, SESSION_COOKIE_NAME, SESSION_MAX_AGE, admin_cookie_path, append_cookies,
clear_cookie_values,
},
state::AppState,
templates::pages::login,
};
use axum::{
Json, Router,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
@@ -87,7 +90,8 @@ async fn login_page(
Some(_) => Some("Login error. Please try again."),
None => None,
};
Html(login::login_page(config, error_msg).into_string()).into_response()
let branding = state.self_hosted_branding().await;
Html(login::login_page(config, branding.as_ref(), error_msg).into_string()).into_response()
}
async fn auth_start(State(state): State<AppState>) -> Response {
@@ -201,7 +205,7 @@ async fn oauth2_callback_finish(
secure
);
let mut cookies = clear_session_cookie_values(config);
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
cookies.push(session_cookie);
oauth_finish_response(StatusCode::OK, &format!("{base}/dashboard"), &cookies)
}
@@ -215,7 +219,7 @@ async fn logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -
}
redirect_with_cookies(
&format!("{}/login", config.base_path),
&clear_session_cookie_values(config),
&clear_cookie_values(SESSION_COOKIE_NAME, config),
)
}
@@ -358,45 +362,6 @@ pub(crate) fn json_string(value: &str) -> String {
.replace('\u{2029}', "\\u2029")
}
fn admin_cookie_path(config: &AdminConfig) -> &str {
if config.base_path.is_empty() {
"/"
} else {
config.base_path.as_str()
}
}
fn clear_cookie_value(name: &str, path: &str) -> String {
format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0")
}
fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec<String> {
let admin_path = admin_cookie_path(config);
let mut cookies = vec![clear_cookie_value(name, admin_path)];
if admin_path != "/" {
cookies.push(clear_cookie_value(name, "/"));
}
cookies
}
fn clear_session_cookie_values(config: &AdminConfig) -> Vec<String> {
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
cookies.extend(clear_cookie_values(LEGACY_SESSION_COOKIE_NAME, config));
cookies
}
fn append_cookie(response: &mut Response, cookie: &str) {
if let Ok(value) = HeaderValue::from_str(cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
fn append_cookies(response: &mut Response, cookies: &[String]) {
for cookie in cookies {
append_cookie(response, cookie);
}
}
fn redirect_with_cookies(url: &str, cookies: &[String]) -> Response {
let mut response = Redirect::to(url).into_response();
append_cookies(&mut response, cookies);
+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,
+83 -88
View File
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::{AdminApiClient, ApiResultExt},
api::client::{AdminApiClient, ApiResult, ApiResultExt},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf::CsrfToken,
@@ -97,41 +98,23 @@ async fn discovery_approve(
request: Request,
) -> Response {
let config = state.config();
let base = &config.base_path;
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, "failed to parse discovery approve form");
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
Some(id) => id,
None => {
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.secure_cookies(),
);
}
};
let (form, guild_id) =
match discovery_form(&state, request, "failed to parse discovery approve form").await {
Ok(value) => value,
Err(response) => return *response,
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let reason = clean_string(form.reason.as_deref().unwrap_or(""));
let result = client
.approve_discovery_application(&guild_id, reason.as_deref())
.await;
let flash = match result.log_error("approve discovery application") {
Some(_) => FlashData::success("Discovery application approved"),
None => FlashData::error("Failed to approve discovery application"),
};
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.secure_cookies(),
finish_discovery(
config,
result,
"approve discovery application",
"Discovery application approved",
"Failed to approve discovery application",
"pending",
)
}
@@ -141,39 +124,21 @@ async fn discovery_reject(
request: Request,
) -> Response {
let config = state.config();
let base = &config.base_path;
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, "failed to parse discovery reject form");
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
Some(id) => id,
None => {
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.secure_cookies(),
);
}
};
let (form, guild_id) =
match discovery_form(&state, request, "failed to parse discovery reject form").await {
Ok(value) => value,
Err(response) => return *response,
};
let reason = form.reason.as_deref().unwrap_or("").trim();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let result = client.reject_discovery_application(&guild_id, reason).await;
let flash = match result.log_error("reject discovery application") {
Some(_) => FlashData::success("Discovery application rejected"),
None => FlashData::error("Failed to reject discovery application"),
};
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.secure_cookies(),
finish_discovery(
config,
result,
"reject discovery application",
"Discovery application rejected",
"Failed to reject discovery application",
"pending",
)
}
@@ -183,38 +148,68 @@ async fn discovery_remove(
request: Request,
) -> Response {
let config = state.config();
let base = &config.base_path;
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, "failed to parse discovery remove form");
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
Some(id) => id,
None => {
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.secure_cookies(),
);
}
};
let (form, guild_id) =
match discovery_form(&state, request, "failed to parse discovery remove form").await {
Ok(value) => value,
Err(response) => return *response,
};
let reason = form.reason.as_deref().unwrap_or("").trim();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let result = client.remove_from_discovery(&guild_id, reason).await;
let flash = match result.log_error("remove discovery guild") {
Some(_) => FlashData::success("Guild removed from discovery"),
None => FlashData::error("Failed to remove guild from discovery"),
finish_discovery(
config,
result,
"remove discovery guild",
"Guild removed from discovery",
"Failed to remove guild from discovery",
"listed",
)
}
async fn discovery_form(
state: &AppState,
request: Request,
log_label: &str,
) -> Result<(DiscoveryActionForm, String), Box<Response>> {
let config = state.config();
let target = format!("{}/discovery", config.base_path);
let form = match Form::<DiscoveryActionForm>::from_request(request, state).await {
Ok(Form(form)) => form,
Err(error) => {
tracing::warn!(%error, "{log_label}");
return Err(Box::new(flash::redirect_with_flash(
&target,
FlashData::error("Invalid form data"),
config.secure_cookies(),
)));
}
};
let Some(guild_id) = clean_string(form.guild_id.as_deref().unwrap_or("")) else {
return Err(Box::new(flash::redirect_with_flash(
&target,
FlashData::error("Guild ID is required"),
config.secure_cookies(),
)));
};
Ok((form, guild_id))
}
fn finish_discovery<T>(
config: &AdminConfig,
result: ApiResult<T>,
log_label: &str,
ok_msg: &str,
err_msg: &str,
tab: &str,
) -> Response {
let data = if result.log_error(log_label).is_some() {
FlashData::success(ok_msg)
} else {
FlashData::error(err_msg)
};
flash::redirect_with_flash(
&format!("{base}/discovery?tab=listed"),
flash,
&format!("{}/discovery?tab={tab}", config.base_path),
data,
config.secure_cookies(),
)
}
+20 -5
View File
@@ -78,8 +78,12 @@ pub async fn render(
.map(|response| response.emojis)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild emojis"))
.unwrap_or_default();
Some(tabs::emojis::emojis_tab(
config, &guild, &emojis, csrf_token,
Some(tabs::emojis::guild_assets_tab(
config,
&guild,
&emojis,
csrf_token,
tabs::emojis::GuildAssetKind::Emoji,
))
}
"stickers" => {
@@ -92,8 +96,12 @@ pub async fn render(
.map(|response| response.stickers)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild stickers"))
.unwrap_or_default();
Some(tabs::stickers::stickers_tab(
config, &guild, &stickers, csrf_token,
Some(tabs::emojis::guild_assets_tab(
config,
&guild,
&stickers,
csrf_token,
tabs::emojis::GuildAssetKind::Sticker,
))
}
"threads" => {
@@ -181,7 +189,14 @@ pub async fn render(
.await
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
.unwrap_or_default();
Some(tabs::applications::applications_tab(config, &guild, &apps))
Some(
crate::templates::pages::user_detail_tabs::applications::applications_tab(
config,
&apps,
"No applications installed",
"This guild does not have any installed applications.",
),
)
}
"archives" => {
if !acl::has_any_permission(
+2 -23
View File
@@ -32,7 +32,7 @@ pub struct ActionQuery {
use axum::{
Json, Router,
extract::{Path, Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
http::{HeaderValue, StatusCode, header},
middleware::{Next, from_fn, from_fn_with_state},
response::{Html, IntoResponse, Response},
routing::get,
@@ -50,9 +50,6 @@ const HTMX_JS: &str = include_str!("../../static/htmx.min.js");
const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const STRICT_TRANSPORT_SECURITY_VALUE: &str = "max-age=31536000; includeSubDomains; preload";
const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin";
const X_FRAME_OPTIONS_VALUE: &str = "DENY";
const PERMISSIONS_POLICY_VALUE: &str = "accelerometer=(), camera=(), geolocation=(), \
gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
@@ -119,19 +116,7 @@ async fn security_headers_middleware(
let headers = response.headers_mut();
let config = state.config();
set_static_header(
headers,
header::STRICT_TRANSPORT_SECURITY,
STRICT_TRANSPORT_SECURITY_VALUE,
);
set_static_header(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
set_static_header(headers, header::REFERRER_POLICY, REFERRER_POLICY_VALUE);
set_static_header(headers, header::X_FRAME_OPTIONS, X_FRAME_OPTIONS_VALUE);
set_static_header(
headers,
HeaderName::from_static("permissions-policy"),
PERMISSIONS_POLICY_VALUE,
);
fluxer_common::server::set_baseline_security_headers(headers, PERMISSIONS_POLICY_VALUE);
let csp = build_admin_csp(config);
if let Ok(value) = HeaderValue::from_str(&csp) {
@@ -172,12 +157,6 @@ fn csp_origin(endpoint: &str) -> String {
}
}
fn set_static_header(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
headers
.entry(name)
.or_insert(HeaderValue::from_static(value));
}
async fn cache_headers_middleware(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
if response.headers().contains_key(header::CACHE_CONTROL) {
+176 -347
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,28 @@ 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 build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
@@ -1345,129 +1415,62 @@ mod tests {
}
#[test]
fn build_limit_filters_accepts_repeated_and_delimited_values() {
let form = MultiValueForm::parse(
b"traits%5B%5D=staff&traits%5B%5D=partner%2Cvip&guild_features=COMMUNITY%0ANEWS",
);
let filters = build_limit_filters(&form).expect("filters");
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!(
filters.traits,
vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()]
rule.limits,
std::collections::BTreeMap::from([
("feature_animated_avatar".to_owned(), 0),
("feature_animated_banner".to_owned(), 1),
("max_guilds".to_owned(), 250),
])
);
assert_eq!(
filters.guild_features,
vec!["COMMUNITY".to_owned(), "NEWS".to_owned()]
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
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!(
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
"2".to_owned(),
"3".to_owned(),
"4".to_owned(),
"5".to_owned()
]
);
}
#[test]
fn parse_experiment_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
"-1",
"1.0",
"999999999999999999999",
"<script>",
] {
assert_eq!(
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
}
}
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
}
#[test]
fn parse_experiment_country_codes_splits_uppercases_and_dedupes() {
assert_eq!(
parse_experiment_country_codes(" se ,NO\nbr\r\n fi\tSE ,, no ").expect("valid codes"),
vec![
"SE".to_owned(),
"NO".to_owned(),
"BR".to_owned(),
"FI".to_owned()
]
);
assert_eq!(
parse_experiment_country_codes(" \n ").expect("valid codes"),
Vec::<String>::new()
);
}
#[test]
fn parse_experiment_country_codes_rejects_malformed_codes() {
for value in ["S", "SWE", "S1", "5E", "<>", "ÅL"] {
assert_eq!(
parse_experiment_country_codes(&format!("SE,{value}")).expect_err("invalid code"),
"Rollout countries entry 2 must be a two-letter country code",
"{value}"
);
}
}
#[test]
fn parse_experiment_country_codes_rejects_exceeding_the_cap() {
let codes = (b'A'..=b'Z')
.flat_map(|first| {
(b'A'..=b'Z').map(move |second| format!("{}{}", first as char, second as char))
})
.collect::<Vec<_>>();
let at_cap = codes[..EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES].join(" ");
assert_eq!(
parse_experiment_country_codes(&format!("{at_cap} aa"))
.expect("valid codes at cap")
.len(),
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES
);
assert_eq!(
parse_experiment_country_codes(&format!(
"{at_cap} {}",
codes[EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES]
))
.expect_err("too many codes"),
"Rollout countries must contain at most 250 unique codes"
custom.limits,
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
);
}
@@ -1531,54 +1534,6 @@ mod tests {
);
}
#[test]
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
"Rollout salt must be between 1 and 64 characters",
),
(
"domain_migration_rollout_salt=caf%C3%A9",
"Rollout salt must use printable ASCII",
),
(
"domain_migration_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"domain_migration_rollout_country_codes=SE%2CSWE",
"Rollout countries entry 2 must be a two-letter country code",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
@@ -1620,45 +1575,6 @@ mod tests {
);
}
#[test]
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"captcha_cost=999",
"Cost must be a whole number between 1000 and 20000",
),
(
"captcha_cost=20001",
"Cost must be a whole number between 1000 and 20000",
),
(
"captcha_max_counter=99",
"Maximum counter must be a whole number between 100 and 20000",
),
(
"captcha_max_counter=20001",
"Maximum counter must be a whole number between 100 and 20000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
let form = MultiValueForm::parse(
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
);
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
);
}
#[test]
fn build_voice_p2p_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
@@ -1711,38 +1627,6 @@ mod tests {
);
}
#[test]
fn build_voice_p2p_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"voice_p2p_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_p2p_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_p2p_rollout_country_codes=SE%2CSWE",
"Rollout countries entry 2 must be a two-letter country code",
),
(
"voice_p2p_max_participants=5",
"Maximum participants must be a whole number between 2 and 4",
),
(
"voice_p2p_max_participants=1",
"Maximum participants must be a whole number between 2 and 4",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_p2p_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -1753,67 +1637,6 @@ mod tests {
);
}
#[test]
fn build_experiment_delivery_update_rejects_invalid_numbers() {
for (key, message, below_min, above_max) in [
(
"experiment_delivery_poll_interval_seconds",
"Poll interval must be a whole number between 60 and 86400",
"59",
"86401",
),
(
"experiment_delivery_poll_jitter_percent",
"Poll jitter must be a whole number between 0 and 50",
"-1",
"51",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
below_min,
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_experiment_delivery_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
for (interval, jitter) in [(60, 0), (86_400, 50)] {
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
let request = build_experiment_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
);
}
}
#[test]
fn build_experiment_delivery_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
);
let update = build_experiment_delivery_update(&form)
.expect("valid form")
.experiment_delivery
.expect("experiment delivery update");
assert_eq!(update.poll_interval_seconds, Some(900));
assert_eq!(update.poll_jitter_percent, Some(25));
}
#[test]
fn update_limit_rule_values_reads_checked_limit_keys() {
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
@@ -1823,7 +1646,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,
+6 -1
View File
@@ -161,7 +161,12 @@ pub async fn render(
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user applications"))
.unwrap_or_default();
Some(tabs::applications::applications_tab(config, &apps))
Some(tabs::applications::applications_tab(
config,
&apps,
"No applications owned",
"This user does not own any applications.",
))
}
"archives" => {
if !acl::has_any_permission(
-28
View File
@@ -470,31 +470,3 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
url.push_str(&urlencoding::encode(value));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
}
}
-37
View File
@@ -264,36 +264,6 @@ mod tests {
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
}
#[test]
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
);
let body = build_server_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
);
let body = build_server_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], serde_json::json!(false));
}
#[test]
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
let body = build_region_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
@@ -311,13 +281,6 @@ mod tests {
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
}
#[test]
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
}
#[test]
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
+38 -1
View File
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use axum::{
http::{HeaderValue, header},
response::Response,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
@@ -109,10 +114,42 @@ pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
pub(crate) fn admin_cookie_path(config: &AdminConfig) -> &str {
if config.base_path.is_empty() {
"/"
} else {
config.base_path.as_str()
}
}
pub(crate) fn clear_cookie_value(name: &str, path: &str) -> String {
format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0")
}
pub(crate) fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec<String> {
let admin_path = admin_cookie_path(config);
let mut cookies = vec![clear_cookie_value(name, admin_path)];
if admin_path != "/" {
cookies.push(clear_cookie_value(name, "/"));
}
cookies
}
pub(crate) fn append_cookie(response: &mut Response, cookie: &str) {
if let Ok(value) = HeaderValue::from_str(cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
pub(crate) fn append_cookies(response: &mut Response, cookies: &[String]) {
for cookie in cookies {
append_cookie(response, cookie);
}
}
#[cfg(test)]
mod tests {
use super::*;
+11
View File
@@ -88,6 +88,17 @@ impl AppState {
Some(branding)
}
pub async fn self_hosted_branding(&self) -> Option<PremiumBranding> {
if !self.config().self_hosted {
return None;
}
self.premium_branding(&AdminApiClient::anonymous(
self.http_client(),
self.config(),
))
.await
}
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
self.account_identity_settings(client).await.mode
}
-3
View File
@@ -8,13 +8,10 @@
--font-display: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
--color-brand-primary: hsl(242 70% 55%);
--color-brand-primary-dark: hsl(242 70% 47%);
--color-brand-primary-rgb: 93 79 192;
}
:root {
--brand-primary: hsl(242 70% 55%);
--focus-ring-color: hsl(242 70% 55% / 0.45);
--focus-ring-offset: #ffffff;
}
html,
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::AuditLogEntry,
@@ -116,27 +118,7 @@ pub fn audit_logs_for_target(
}))
}
@if has_previous || next_page.is_some() {
div class="flex items-center justify-center gap-2" {
@if has_previous {
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
class="inline-flex items-center justify-center gap-2 font-medium \
rounded-lg bg-neutral-50 text-neutral-700 border \
border-neutral-300 px-3 py-1.5 text-sm" {
"Previous"
}
}
span class="text-sm text-neutral-500" {
"Page " (page_number) " of " (total_pages)
}
@if let Some(page) = next_page {
a href={(tab_href_base) "&audit_logs_page=" (page)}
class="inline-flex items-center justify-center gap-2 font-medium \
rounded-lg bg-neutral-50 text-neutral-700 border \
border-neutral-300 px-3 py-1.5 text-sm" {
"Next"
}
}
}
(pager_with_missing(has_previous.then(|| format!("{tab_href_base}&audit_logs_page={}", current_page - 1)), Some(format!("Page {page_number} of {total_pages}")), next_page.map(|page| format!("{tab_href_base}&audit_logs_page={page}")), true))
}
}
}
@@ -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,
@@ -238,3 +242,28 @@ pub fn secondary_button_link(label: &str, href: &str) -> Markup {
}
}
}
pub(crate) fn form_field_with_id(
label: &str,
id: &str,
name: &str,
input_type: &str,
value: &str,
placeholder: &str,
required: bool,
) -> Markup {
html! {
div class="space-y-1" {
label for=(id) class="block text-sm font-medium text-neutral-700" {
(label)
}
input type=(input_type) id=(id) name=(name) value=(value)
placeholder=(placeholder) required[required]
step=[if input_type == "number" { Some("any") } else { None }]
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
}
}
}
@@ -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");
}
}
@@ -26,3 +26,5 @@ pub mod tooltip;
pub mod user_display;
pub mod user_profile_badges;
pub mod voice;
pub mod pagination;
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, html};
pub fn pager(prev: Option<String>, label: Option<String>, next: Option<String>) -> Markup {
pager_with_missing(prev, label, next, false)
}
pub fn pager_with_missing(
prev: Option<String>,
label: Option<String>,
next: Option<String>,
omit_missing: bool,
) -> Markup {
html! {
div class="mt-4 flex items-center justify-between gap-3" {
@if let Some(url) = prev {
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "← Previous" }
} @else if !omit_missing {
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "← Previous" }
}
@if let Some(text) = label { span class="text-sm text-neutral-500" { (text) } }
@if let Some(url) = next {
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "Next →" }
} @else if !omit_missing {
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "Next →" }
}
}
}
}
@@ -1,5 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::ReportEntry,
config::AdminConfig,
templates::components::{
badge::BadgeVariant,
media::{guild_icon_url, initials},
},
};
use maud::{Markup, html};
const REPORT_CATEGORIES: &[(&str, &str)] = &[
@@ -52,93 +61,59 @@ 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"));
pub(crate) fn report_status(status: i32) -> (&'static str, BadgeVariant) {
match status {
0 => ("Pending", BadgeVariant::Warning),
1 => ("Resolved", BadgeVariant::Success),
_ => ("Unknown", BadgeVariant::Default),
}
}
#[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);
}
pub(crate) fn report_type(report_type: i32) -> (&'static str, BadgeVariant) {
match report_type {
0 => ("Message", BadgeVariant::Info),
1 => ("User", BadgeVariant::Default),
2 => ("Guild", BadgeVariant::Warning),
_ => ("Unknown", BadgeVariant::Default),
}
}
#[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")));
pub(crate) fn message_lookup_href(
base: &str,
channel_id: &str,
message_id: Option<&str>,
) -> String {
let mut href = format!(
"{base}/messages?channel_id={}&context_limit=50",
urlencoding::encode(channel_id)
);
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
href.push_str("&message_id=");
href.push_str(&urlencoding::encode(message_id));
}
href
}
#[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}");
pub(crate) fn reported_guild_icon(
config: &AdminConfig,
report: &ReportEntry,
guild_id: &str,
label: &str,
) -> Markup {
match guild_icon_url(
config,
guild_id,
report.reported_guild_icon_hash.as_deref(),
80,
true,
) {
Some(url) => html! {
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
},
None => html! {
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
(initials(label))
}
},
}
}
@@ -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"));
}
}
+28 -5
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{api::types::FlashLevel, config::AdminConfig, middleware::auth::AuthContext};
use crate::{
api::types::{FlashLevel, PremiumBranding},
config::AdminConfig,
middleware::auth::AuthContext,
};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use super::components::drawer::drawer_controller_script;
@@ -11,6 +15,28 @@ use super::layout_scripts::{
};
use super::layout_sidebar::render_sidebar;
const DEFAULT_PRODUCT_NAME: &str = "Fluxer";
pub fn product_name(branding: Option<&PremiumBranding>) -> &str {
branding
.and_then(|branding| branding.product_name.as_deref())
.unwrap_or(DEFAULT_PRODUCT_NAME)
}
pub fn favicon_links(config: &AdminConfig, branding: Option<&PremiumBranding>) -> Markup {
let cdn = &config.static_cdn_endpoint;
html! {
@if let Some(favicon_url) = branding.and_then(|branding| branding.favicon_url.as_deref()) {
link rel="icon" href=(favicon_url);
} @else {
link rel="icon" type="image/x-icon" href={(cdn) "/web/favicon.ico"};
link rel="apple-touch-icon" href={(cdn) "/web/apple-touch-icon.png"};
link rel="icon" type="image/png" sizes="32x32" href={(cdn) "/web/favicon-32x32.png"};
link rel="icon" type="image/png" sizes="16x16" href={(cdn) "/web/favicon-16x16.png"};
}
}
}
fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String {
format!("{base_path}{path}?t={asset_version}")
}
@@ -72,10 +98,7 @@ pub fn admin_layout_ext(
title { (title) " ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
link rel="icon" type="image/png" sizes="32x32" href={(config.static_cdn_endpoint) "/web/favicon-32x32.png"};
link rel="icon" type="image/png" sizes="16x16" href={(config.static_cdn_endpoint) "/web/favicon-16x16.png"};
(favicon_links(config, auth.branding.as_ref()))
script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {}
}
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
@@ -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}");
}
}
}
+4 -63
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::utils::plural::noun_for;
use crate::{
api::types::AuditLogsListResponse,
@@ -14,7 +16,7 @@ use crate::{
layout::admin_layout,
},
};
use maud::{Markup, PreEscaped, html};
use maud::{Markup, html};
use super::audit_logs_table::{audit_log_table_body, audit_log_table_headers};
@@ -141,23 +143,7 @@ pub fn audit_logs_page(
})))
}
@if total > params.limit as u64 {
div class="mt-4 flex items-center justify-between" {
@if params.current_page > 0 {
a href=(build_pagination_url(base, params.current_page - 1, params))
class="text-sm text-neutral-900 underline" {
(PreEscaped("&larr; Previous"))
}
} @else { span {} }
span class="text-sm text-neutral-500" {
"Page " (page_number) " of " (total_pages)
}
@if let Some(page) = next_page {
a href=(build_pagination_url(base, page, params))
class="text-sm text-neutral-900 underline" {
(PreEscaped("Next &rarr;"))
}
} @else { span {} }
}
(pager_with_missing((params.current_page > 0).then(|| build_pagination_url(base, params.current_page - 1, params)), Some(format!("Page {page_number} of {total_pages}")), next_page.map(|page| build_pagination_url(base, page, params)), true))
}
}
}
@@ -171,48 +157,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"
);
}
}
@@ -10,7 +10,7 @@ use crate::{
resource_link::{ResourceType, resource_link},
table::{table_body, table_cell, table_head, table_header_cell, table_row},
},
utils::{bigint::format_discriminator, user_tag::user_tag},
utils::{bigint::format_discriminator, user_tag::display_with_tag},
};
use maud::{Markup, html};
@@ -43,19 +43,12 @@ fn type_label(target_type: &str) -> String {
}
fn user_label(user: &AuditLogUserSummary) -> String {
let tag = user_tag(
display_with_tag(
user.global_name.as_deref(),
&user.username,
&format_discriminator(&user.discriminator),
false,
);
match user
.global_name
.as_ref()
.filter(|value| !value.trim().is_empty())
{
Some(global_name) => format!("{global_name} ({tag})"),
None => tag,
}
)
}
fn channel_label(channel: &AuditLogChannelSummary) -> String {
@@ -326,58 +319,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"));
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::{DELETION_REASONS, DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
use crate::{
acl,
config::AdminConfig,
@@ -19,160 +20,6 @@ use crate::{
};
use maud::{Markup, html};
const DELETION_REASONS: &[(&str, &str)] = &[
("1", "User requested"),
("2", "Other"),
("3", "Spam"),
("4", "Cheating or exploitation"),
("5", "Coordinated raiding or manipulation"),
("6", "Automation or self-bot usage"),
("7", "Nonconsensual sexual content"),
("8", "Scam or social engineering"),
("9", "Child sexual content"),
("10", "Privacy violation or doxxing"),
("11", "Harassment or bullying"),
("12", "Payment fraud"),
("13", "Child safety violation"),
("14", "Billing dispute or abuse"),
("15", "Unsolicited explicit content"),
("16", "Graphic violence"),
("17", "Ban evasion"),
("18", "Token or credential scam"),
("19", "Inactivity"),
("20", "Hate speech or extremist content"),
("21", "Malicious links or malware distribution"),
("22", "Impersonation or fake identity"),
];
struct UserFlag {
name: &'static str,
value: u64,
}
const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
UserFlag {
name: "STAFF",
value: 1 << 0,
},
UserFlag {
name: "PARTNER",
value: 1 << 2,
},
UserFlag {
name: "BUG_HUNTER",
value: 1 << 3,
},
UserFlag {
name: "FRIENDLY_BOT",
value: 1 << 4,
},
UserFlag {
name: "FRIENDLY_BOT_MANUAL_APPROVAL",
value: 1 << 5,
},
UserFlag {
name: "SPAMMER",
value: 1 << 6,
},
UserFlag {
name: "PROFILE_HIDDEN",
value: 1 << 7,
},
UserFlag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: 1 << 33,
},
UserFlag {
name: "DELETED",
value: 1 << 34,
},
UserFlag {
name: "SELF_DELETED",
value: 1 << 36,
},
UserFlag {
name: "DISABLED",
value: 1 << 38,
},
UserFlag {
name: "HAS_SESSION_STARTED",
value: 1 << 39,
},
UserFlag {
name: "RATE_LIMIT_BYPASS",
value: 1 << 47,
},
UserFlag {
name: "REPORT_BANNED",
value: 1 << 48,
},
UserFlag {
name: "VERIFIED_NOT_UNDERAGE",
value: 1 << 49,
},
UserFlag {
name: "ACCOUNT_LIMITED",
value: 1 << 50,
},
UserFlag {
name: "HAS_DISMISSED_PREMIUM_ONBOARDING",
value: 1 << 51,
},
UserFlag {
name: "APP_STORE_REVIEWER",
value: 1 << 53,
},
UserFlag {
name: "STAFF_HIDDEN",
value: 1 << 57,
},
UserFlag {
name: "AGE_VERIFIED_ADULT",
value: 1 << 60,
},
UserFlag {
name: "LIMIT_EXEMPT",
value: 1 << 62,
},
];
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
"RAID_DETECTED",
"TEXT_CHANNEL_FLEXIBLE_NAMES",
"HIDE_OWNER_CROWN",
"MORE_EMOJI",
"MORE_STICKERS",
"UNLIMITED_EMOJI",
"UNLIMITED_STICKERS",
"EXPRESSION_PURGE_ALLOWED",
"VANITY_URL",
"DISCOVERABLE",
"PARTNERED",
"VERIFIED",
"VIP_VOICE",
"VOICE_E2EE",
"UNAVAILABLE_FOR_EVERYONE",
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
"UNAVAILABLE_HIDDEN",
"VISIONARY",
"LARGE_GUILD_OVERRIDE",
"VERY_LARGE_GUILD",
"ANNOUNCEMENT_CHANNELS_DISABLED",
];
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn guild_feature_label(feature: &str) -> String {
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
format!("{feature} (deprecated, removal only)")
@@ -232,7 +79,7 @@ fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@for flag in PATCHABLE_USER_FLAGS {
@for flag in crate::admin_flags::USER_FLAGS {
(checkbox(prefix, &flag.value.to_string(), flag.name, false, true))
}
}
@@ -413,56 +260,3 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_grid_offers_only_the_opt_in_clone_features() {
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn 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();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_hides_the_email_choices() {
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
assert!(!deletion.contains("Email each user"));
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""#));
}
}
@@ -18,7 +18,7 @@ use crate::{
},
utils::bigint::format_discriminator,
utils::timestamps::format_admin_timestamp,
utils::user_tag::user_tag,
utils::user_tag::display_with_tag,
};
use maud::{Markup, html};
@@ -75,11 +75,12 @@ fn owner_display(
let Some(discriminator) = discriminator else {
return owner_id.to_owned();
};
let tag = user_tag(username, &format_discriminator(discriminator), false);
match global_name.filter(|value| !value.trim().is_empty()) {
Some(global_name) => format!("{global_name} ({tag})"),
None => tag,
}
display_with_tag(
global_name,
username,
&format_discriminator(discriminator),
false,
)
}
fn nsfw_badge(level: Option<i32>) -> Markup {
@@ -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
);
}
}
@@ -88,10 +88,20 @@ pub fn simple_tab_content(
csrf_token,
admin_acls,
),
"emojis" => guild_detail_tabs::emojis::emojis_tab(config, &guild_info, &[], csrf_token),
"stickers" => {
guild_detail_tabs::stickers::stickers_tab(config, &guild_info, &[], csrf_token)
}
"emojis" => guild_detail_tabs::emojis::guild_assets_tab(
config,
&guild_info,
&[],
csrf_token,
guild_detail_tabs::emojis::GuildAssetKind::Emoji,
),
"stickers" => guild_detail_tabs::emojis::guild_assets_tab(
config,
&guild_info,
&[],
csrf_token,
guild_detail_tabs::emojis::GuildAssetKind::Sticker,
),
_ => guild_detail_tabs::overview::overview_tab(config, guild, csrf_token),
}
}
@@ -1,109 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{Application, GuildInfo},
config::AdminConfig,
templates::components::page_container::card_with_header,
};
use maud::{Markup, html};
pub fn applications_tab(
config: &AdminConfig,
_guild: &GuildInfo,
applications: &[Application],
) -> Markup {
let base = &config.base_path;
html! {
(card_with_header(
&format!("Applications ({})", applications.len()),
html! {
@if applications.is_empty() {
div class="flex flex-col items-center justify-center py-12 text-center" {
p class="text-sm font-medium text-neutral-900" {
"No applications installed"
}
p class="mt-1 text-sm text-neutral-500" {
"This guild does not have any installed applications."
}
}
} @else {
div class="space-y-4" {
@for app in applications {
(application_card(base, app))
}
}
}
},
))
}
}
fn application_card(base: &str, app: &Application) -> Markup {
let bot_display = match (&app.bot_user_id, &app.bot_username, &app.bot_discriminator) {
(Some(bid), Some(bname), Some(bdisc)) => {
let display = match &app.bot_global_name {
Some(gn) if !gn.trim().is_empty() => {
format!("{} ({}#{})", gn, bname, bdisc)
}
_ => format!("{}#{}", bname, bdisc),
};
Some((bid.clone(), display))
}
(Some(bid), _, _) => Some((bid.clone(), bid.clone())),
_ => None,
};
html! {
div class="rounded-lg border border-neutral-200 bg-white p-4" {
div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" {
div class="min-w-0 flex-1 space-y-2" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-base font-medium text-neutral-900" {
(app.name)
}
@if app.bot_user_id.is_some() {
span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium \
uppercase text-blue-700" {
"Bot"
}
} @else {
span class="rounded bg-neutral-200 px-2 py-0.5 text-xs font-medium \
uppercase text-neutral-700" {
"OAuth2 App"
}
}
}
p class="break-all text-sm text-neutral-500" {
(app.id)
}
@if let Some((ref bid, ref display)) = bot_display {
p class="text-sm text-neutral-500" {
"Bot user: "
a href={(base) "/users/" (bid)}
class="transition-colors hover:text-blue-600 hover:underline" {
(display)
}
}
} @else {
p class="text-sm text-neutral-500" {
"No bot user (OAuth2 only)"
}
}
p class="text-sm text-neutral-500" {
(app.oauth2_redirect_uris.len()) " redirect URI"
@if app.oauth2_redirect_uris.len() != 1 { "s" }
" \u{00b7} Client secret "
@if app.has_client_secret { "set" } @else { "not set" }
" \u{00b7} Bot token "
@if app.has_bot_token { "set" } @else { "not set" }
}
}
a href={(base) "/applications/" (app.id)}
class="inline-flex items-center rounded-md bg-brand-primary px-4 py-2 \
text-sm font-medium text-white shadow-sm hover:bg-brand-primary-dark" {
"View Details"
}
}
}
}
}
@@ -11,25 +11,36 @@ use crate::{
};
use maud::{Markup, html};
pub fn emojis_tab(
#[derive(Clone, Copy)]
pub enum GuildAssetKind {
Emoji,
Sticker,
}
pub fn guild_assets_tab(
config: &AdminConfig,
guild: &GuildInfo,
emojis: &[GuildAssetItem],
items: &[GuildAssetItem],
csrf_token: &str,
kind: GuildAssetKind,
) -> Markup {
let base = &config.base_path;
let (title, empty) = match kind {
GuildAssetKind::Emoji => ("Emojis", "No custom emojis found for this guild."),
GuildAssetKind::Sticker => ("Stickers", "No stickers found for this guild."),
};
html! {
(card_with_header(
&format!("Emojis ({})", emojis.len()),
&format!("{title} ({})", items.len()),
html! {
@if emojis.is_empty() {
@if items.is_empty() {
p class="text-sm text-neutral-500" {
"No custom emojis found for this guild."
(empty)
}
} @else {
div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" {
@for emoji in emojis {
(emoji_card(base, &guild.id, emoji, csrf_token))
@for item in items {
(asset_card(base, &guild.id, item, csrf_token, kind))
}
}
}
@@ -38,36 +49,55 @@ pub fn emojis_tab(
}
}
fn emoji_card(base: &str, guild_id: &str, emoji: &GuildAssetItem, csrf_token: &str) -> Markup {
fn asset_card(
base: &str,
guild_id: &str,
item: &GuildAssetItem,
csrf_token: &str,
kind: GuildAssetKind,
) -> Markup {
let sticker = matches!(kind, GuildAssetKind::Sticker);
let (query, input_name, button_label) = match kind {
GuildAssetKind::Emoji => (
"?tab=emojis&action=delete_emoji",
"emoji_id",
"Delete Emoji",
),
GuildAssetKind::Sticker => (
"?tab=stickers&action=delete_sticker",
"sticker_id",
"Delete Sticker",
),
};
html! {
div class="overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm" {
div class="flex flex-col" {
div class="flex h-32 items-center justify-center bg-neutral-100 p-6" {
img src=(emoji.media_url) alt=(emoji.name)
img src=(item.media_url) alt=(item.name)
class="max-h-full max-w-full object-contain"
loading="lazy";
}
div class="flex-1 space-y-3 px-4 py-3" {
div class="flex items-center justify-between" {
div class=(if sticker {"flex-1 space-y-1 px-4 py-3"} else {"flex-1 space-y-3 px-4 py-3"}) {
div class=(if sticker {"flex items-center justify-between gap-2"} else {"flex items-center justify-between"}) {
span class="text-sm font-semibold text-neutral-900" {
(emoji.name)
(item.name)
}
@if emoji.animated {
(badge("Animated", BadgeVariant::Default))
@if sticker || item.animated {
(badge(if item.animated {"Animated"} else {"Static"}, BadgeVariant::Default))
}
}
p class="break-words text-xs text-neutral-500" {
"ID: " (emoji.id)
"ID: " (item.id)
}
a href={(base) "/users/" (emoji.creator_id)}
a href={(base) "/users/" (item.creator_id)}
class="text-xs text-blue-600 hover:underline" {
"Uploader: " (emoji.creator_id)
"Uploader: " (item.creator_id)
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=emojis&action=delete_emoji"} {
action={(base) "/guilds/" (guild_id) (query)} class=[sticker.then_some("mt-4")] {
(csrf_input(csrf_token))
input type="hidden" name="emoji_id" value=(emoji.id);
(danger_button("Delete Emoji"))
input type="hidden" name=(input_name) value=(item.id);
(danger_button(button_label))
}
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::{DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
use crate::{
acl,
api::types::GuildInfo,
@@ -11,49 +12,10 @@ use crate::{
};
use maud::{Markup, html};
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
"RAID_DETECTED",
"TEXT_CHANNEL_FLEXIBLE_NAMES",
"HIDE_OWNER_CROWN",
"MORE_EMOJI",
"MORE_STICKERS",
"UNLIMITED_EMOJI",
"UNLIMITED_STICKERS",
"EXPRESSION_PURGE_ALLOWED",
"VANITY_URL",
"DISCOVERABLE",
"PARTNERED",
"VERIFIED",
"VIP_VOICE",
"VOICE_E2EE",
"UNAVAILABLE_FOR_EVERYONE",
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
"UNAVAILABLE_HIDDEN",
"VISIONARY",
"LARGE_GUILD_OVERRIDE",
"VERY_LARGE_GUILD",
"ANNOUNCEMENT_CHANNELS_DISABLED",
];
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn feature_label(feature: &str) -> String {
if DEPRECATED_FEATURES.contains(&feature) {
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
format!("{feature} (deprecated, no longer enforced)")
} else {
feature.to_owned()
@@ -182,56 +144,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"));
}
}
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager;
use crate::utils::plural::count_noun;
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
@@ -11,7 +13,7 @@ use crate::{
media::user_avatar_url,
page_container::card_with_header,
},
utils::{bigint::format_discriminator, user_tag::user_tag},
utils::{bigint::format_discriminator, user_tag::display_with_tag},
};
use maud::{Markup, html};
@@ -64,35 +66,7 @@ pub fn members_tab(
}
@if total_pages > 1 {
div class="mt-4 flex items-center justify-between border-t pt-4" {
@if has_previous {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page - 1)}
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
"\u{2190} Previous"
}
} @else {
span class="inline-flex items-center rounded-md border border-neutral-200 \
bg-neutral-50 px-3 py-2 text-sm font-medium text-neutral-400" {
"\u{2190} Previous"
}
}
p class="text-sm text-neutral-500" {
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
"Next \u{2192}"
}
} @else {
span class="inline-flex items-center rounded-md border border-neutral-200 \
bg-neutral-50 px-3 py-2 text-sm font-medium text-neutral-400" {
"Next \u{2192}"
}
}
}
(pager(has_previous.then(|| format!("{base}/guilds/{}?tab=members&page={}", guild.id, page - 1)), Some(format!("Page {} of {total_pages}", u64::from(page) + 1)), next_page.map(|next| format!("{base}/guilds/{}?tab=members&page={next}", guild.id))))
}
}
}
@@ -105,15 +79,12 @@ fn member_card(
member: &GuildMember,
csrf_token: &str,
) -> Markup {
let tag = user_tag(
let display = display_with_tag(
member.user.global_name.as_deref(),
&member.user.username,
&format_discriminator(&member.user.discriminator),
member.user.bot,
);
let display = match &member.user.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{gn} ({tag})"),
_ => tag,
};
let user_url = format!("{base}/users/{}", member.user.id);
let avatar_url = user_avatar_url(
config,
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod applications;
pub mod archives;
pub mod audit_log;
pub mod emojis;
@@ -10,26 +9,21 @@ pub mod moderation;
pub mod overview;
pub mod reports;
pub mod settings;
pub mod stickers;
pub mod threads;
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
use crate::{api::types::GuildDetailInfo, utils::user_tag::display_with_tag};
pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
let Some(username) = guild.owner_username.as_deref() else {
let (Some(username), Some(discriminator)) = (
guild.owner_username.as_deref(),
guild.owner_discriminator.as_deref(),
) else {
return guild.owner_id.clone();
};
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
.filter(|name| !name.trim().is_empty())
{
format!("{global_name} ({tag})")
} else {
tag
}
display_with_tag(
guild.owner_global_name.as_deref(),
username,
discriminator,
false,
)
}
@@ -1,13 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::report_category::report_status;
use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::{
components::page_container::card_with_header,
pages::user_detail_tabs::reports::{
format_status, report_tab_table, reported_at_cell, reporter_label,
type_and_category_cell,
report_tab_table, reported_at_cell, reporter_label, type_and_category_cell,
},
},
};
@@ -94,11 +95,11 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
}
}
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(format_status(report.status))
(report_status(report.status).0)
}
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
(format_status(report.status))
(report_status(report.status).0)
}
a href={(base) "/reports/" (report.report_id)}
class="inline-flex items-center rounded-md border border-neutral-300 \
@@ -110,49 +111,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)"));
}
}
@@ -1,78 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{GuildAssetItem, GuildInfo},
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button},
page_container::card_with_header,
},
};
use maud::{Markup, html};
pub fn stickers_tab(
config: &AdminConfig,
guild: &GuildInfo,
stickers: &[GuildAssetItem],
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
(card_with_header(
&format!("Stickers ({})", stickers.len()),
html! {
@if stickers.is_empty() {
p class="text-sm text-neutral-500" {
"No stickers found for this guild."
}
} @else {
div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" {
@for sticker in stickers {
(sticker_card(base, &guild.id, sticker, csrf_token))
}
}
}
},
))
}
}
fn sticker_card(base: &str, guild_id: &str, sticker: &GuildAssetItem, csrf_token: &str) -> Markup {
html! {
div class="overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm" {
div class="flex flex-col" {
div class="flex h-32 items-center justify-center bg-neutral-100 p-6" {
img src=(sticker.media_url) alt=(sticker.name)
class="max-h-full max-w-full object-contain"
loading="lazy";
}
div class="flex-1 space-y-1 px-4 py-3" {
div class="flex items-center justify-between gap-2" {
span class="text-sm font-semibold text-neutral-900" {
(sticker.name)
}
(badge(
if sticker.animated { "Animated" } else { "Static" },
BadgeVariant::Default,
))
}
p class="break-words text-xs text-neutral-500" {
"ID: " (sticker.id)
}
a href={(base) "/users/" (sticker.creator_id)}
class="text-xs text-blue-600 hover:underline" {
"Uploader: " (sticker.creator_id)
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=stickers&action=delete_sticker"}
class="mt-4" {
(csrf_input(csrf_token))
input type="hidden" name="sticker_id" value=(sticker.id);
(danger_button("Delete Sticker"))
}
}
}
}
}
}
+22 -48
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager;
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
@@ -15,7 +17,7 @@ use crate::{
},
layout::admin_layout,
},
utils::{forms::parse_comma_separated, user_tag::user_tag},
utils::{forms::parse_comma_separated, user_tag::display_with_tag},
};
use maud::{Markup, html};
@@ -164,38 +166,14 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
.page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-6 flex items-center justify-center gap-3" {
@if has_previous {
a href=(pagination_url(&config.base_path, params, params.page - 1))
class="rounded-lg border border-neutral-300 bg-white px-6 py-2 \
font-medium text-neutral-900 text-sm no-underline \
transition-colors hover:bg-neutral-50" {
"\u{2190} Previous"
}
} @else {
div class="cursor-not-allowed rounded-lg border border-neutral-200 \
bg-neutral-100 px-6 py-2 font-medium text-neutral-400 text-sm" {
"\u{2190} Previous"
}
}
span class="text-neutral-600 text-sm" {
"Page " (u64::from(params.page) + 1) " of " (total_pages)
}
@if let Some(next_page) = next_page {
a href=(pagination_url(&config.base_path, params, next_page))
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
text-white no-underline transition-colors hover:bg-neutral-800" {
"Next \u{2192}"
}
} @else {
div class="cursor-not-allowed rounded-lg bg-neutral-100 px-6 py-2 \
font-medium text-neutral-400 text-sm" {
"Next \u{2192}"
}
}
}
}
pager(
has_previous.then(|| pagination_url(&config.base_path, params, params.page - 1)),
Some(format!(
"Page {} of {total_pages}",
u64::from(params.page) + 1
)),
next_page.map(|page| pagination_url(&config.base_path, params, page)),
)
}
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
@@ -262,23 +240,19 @@ fn render_guilds_cards(config: &AdminConfig, guilds: &[GuildInfo]) -> Markup {
}
}
fn owner_display(guild: &GuildInfo) -> String {
let Some(username) = guild.owner_username.as_deref() else {
pub(crate) fn owner_display(guild: &GuildInfo) -> String {
let (Some(username), Some(discriminator)) = (
guild.owner_username.as_deref(),
guild.owner_discriminator.as_deref(),
) else {
return guild.owner_id.clone();
};
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
.filter(|name| !name.trim().is_empty())
{
format!("{global_name} ({tag})")
} else {
tag
}
display_with_tag(
guild.owner_global_name.as_deref(),
username,
discriminator,
false,
)
}
fn guild_icon(config: &AdminConfig, guild: &GuildInfo) -> Markup {
@@ -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,
},
@@ -25,6 +28,7 @@ const CATEGORY_ORDER: &[&str] = &[
"expressions",
"files",
"social",
"media",
];
pub fn limit_config_page(
@@ -81,7 +85,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 +155,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 +187,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"))
}
}
}
}
+12 -4
View File
@@ -1,9 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use crate::{
api::types::PremiumBranding,
config::AdminConfig,
templates::layout::{favicon_links, product_name},
};
use maud::{DOCTYPE, Markup, html};
pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
pub fn login_page(
config: &AdminConfig,
branding: Option<&PremiumBranding>,
error_message: Option<&str>,
) -> Markup {
let base = &config.base_path;
html! {
(DOCTYPE)
@@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
title { "Login ~ Fluxer Admin" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href={(base) "/static/app.css"};
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
(favicon_links(config, branding))
}
body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" {
main class="w-full max-w-sm" {
@@ -38,7 +46,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
bg-neutral-900 text-white hover:bg-neutral-800 \
px-4 py-2 text-base w-full sm:w-fit \
focus:ring-offset-white" {
span { "Sign in with Fluxer" }
span { "Sign in with " (product_name(branding)) }
}
}
}
File diff suppressed because it is too large. Load diff
+15 -202
View File
@@ -1,5 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::templates::components::report_category::{
message_lookup_href, report_status, report_type, reported_guild_icon,
};
use crate::{
api::types::{ReportEntry, ReportReasonEntry, SearchReportsResponse},
config::AdminConfig,
@@ -9,7 +15,7 @@ use crate::{
badge::{BadgeVariant, badge},
drawer::{DrawerSide, DrawerWidth, drawer},
form::{secondary_button_link, select_input, submit_button, text_input},
media::{guild_icon_url, initials, user_avatar_url},
media::user_avatar_url,
page_container::page_header_with_actions,
report_category::{reason_repeats_category, report_category, report_category_options},
report_webhook::{
@@ -186,23 +192,6 @@ fn reason_select(reasons: &[ReportReasonEntry], selected: &str) -> Markup {
select_input("reason", "Reason", &options, selected)
}
fn format_status(status: i32) -> (&'static str, BadgeVariant) {
match status {
0 => ("Pending", BadgeVariant::Warning),
1 => ("Resolved", BadgeVariant::Success),
_ => ("Unknown", BadgeVariant::Default),
}
}
fn format_report_type(report_type: i32) -> (&'static str, BadgeVariant) {
match report_type {
0 => ("Message", BadgeVariant::Info),
1 => ("User", BadgeVariant::Default),
2 => ("Guild", BadgeVariant::Warning),
_ => ("Unknown", BadgeVariant::Default),
}
}
fn reporter_label(report: &ReportEntry) -> Markup {
if let Some(username) = &report.reporter_username {
return tagged_user(
@@ -245,18 +234,6 @@ fn reported_user_label(report: &ReportEntry) -> String {
)
}
fn message_lookup_href(base: &str, channel_id: &str, message_id: Option<&str>) -> String {
let mut href = format!(
"{base}/messages?channel_id={}&context_limit=50",
urlencoding::encode(channel_id)
);
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
href.push_str("&message_id=");
href.push_str(&urlencoding::encode(message_id));
}
href
}
fn reporter_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
let base = &config.base_path;
let primary = reporter_label(report);
@@ -341,30 +318,6 @@ fn reported_guild_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
}
}
fn reported_guild_icon(
config: &AdminConfig,
report: &ReportEntry,
guild_id: &str,
label: &str,
) -> Markup {
match guild_icon_url(
config,
guild_id,
report.reported_guild_icon_hash.as_deref(),
80,
true,
) {
Some(url) => html! {
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
},
None => html! {
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
(initials(label))
}
},
}
}
fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
let base = &config.base_path;
let channel_label = report
@@ -460,7 +413,7 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
(format_admin_timestamp(&report.reported_at))
}
@let (type_label, type_variant) = format_report_type(report.report_type);
@let (type_label, type_variant) = report_type(report.report_type);
(badge(type_label, type_variant))
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
@let repeats = report.category.as_deref().zip(reason_label).is_some_and(|(category, label)| reason_repeats_category(category, label));
@@ -495,7 +448,7 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reported_cell(config, report))
}
@let (status_label, status_variant) = format_status(report.status);
@let (status_label, status_variant) = report_status(report.status);
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
span data-status-pill=(report.report_id) {
(badge(status_label, status_variant))
@@ -567,29 +520,12 @@ fn reports_pagination(
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-4 flex items-center justify-between" {
@if page > 0 {
a href=(reports_url(config, filters, page - 1, limit))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
(PreEscaped("&larr; Previous"))
}
} @else {
span {}
}
span class="text-neutral-500 text-sm" {
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if let Some(next_page) = next_page {
a href=(reports_url(config, filters, next_page, limit))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
(PreEscaped("Next &rarr;"))
}
} @else {
span {}
}
}
}
pager_with_missing(
(page > 0).then(|| reports_url(config, filters, page - 1, limit)),
Some(format!("Page {} of {total_pages}", u64::from(page) + 1)),
next_page.map(|page| reports_url(config, filters, page, limit)),
true,
)
}
fn push_param(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
@@ -640,126 +576,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
);
}
}
@@ -11,7 +11,7 @@ use crate::{
},
page_container::{card, page_header},
},
layout::admin_layout,
layout::{admin_layout, product_name},
},
};
use maud::{Markup, html};
@@ -37,9 +37,9 @@ pub fn system_dm_page(
"Send a system DM"
}
p class="text-sm text-neutral-500" {
"Sent from the official Fluxer system account. Each recipient \
will receive the same content as a DM. Progress is observable \
on the "
"Sent from the official " (product_name(auth.branding.as_ref()))
" system account. Each recipient will receive the same content \
as a DM. Progress is observable on the "
a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" {
"Jobs page"
}
@@ -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 {
@@ -6,7 +6,12 @@ use crate::{
};
use maud::{Markup, html};
pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> Markup {
pub fn applications_tab(
config: &AdminConfig,
applications: &[Application],
empty_title: &str,
empty_body: &str,
) -> Markup {
let base = &config.base_path;
html! {
(card_with_header(
@@ -15,10 +20,10 @@ pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> M
@if applications.is_empty() {
div class="flex flex-col items-center justify-center py-12 text-center" {
p class="text-sm font-medium text-neutral-900" {
"No applications owned"
(empty_title)
}
p class="mt-1 text-sm text-neutral-500" {
"This user does not own any applications."
(empty_body)
}
}
} @else {
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::{
api::types::DmChannel, config::AdminConfig,
templates::components::page_container::card_with_header,
@@ -144,27 +146,14 @@ fn dm_pagination(
return html! {};
}
html! {
div class="flex items-center justify-center gap-2 rounded-lg border \
border-neutral-200 bg-neutral-50 px-4 py-2" {
@if has_previous {
@if let Some(fid) = first_id {
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_after=" (fid)}
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
transition-colors hover:bg-white hover:text-neutral-900" {
"Previous"
}
}
}
@if has_next {
@if let Some(lid) = last_id {
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_before=" (lid)}
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
transition-colors hover:bg-white hover:text-neutral-900" {
"Next"
}
}
}
}
}
pager_with_missing(
first_id.filter(|_| has_previous).map(|id| {
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_after={id}")
}),
None,
last_id.filter(|_| has_next).map(|id| {
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_before={id}")
}),
true,
)
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::DELETION_REASONS;
use crate::utils::plural::{count_noun, noun_for};
use crate::{
acl,
@@ -30,31 +31,6 @@ const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
("0", "Permanent"),
];
const DELETION_REASONS: &[(&str, &str)] = &[
("1", "User requested"),
("2", "Other"),
("3", "Spam"),
("4", "Cheating or exploitation"),
("5", "Coordinated raiding or manipulation"),
("6", "Automation or self-bot usage"),
("7", "Nonconsensual sexual content"),
("8", "Scam or social engineering"),
("9", "Child sexual content"),
("10", "Privacy violation or doxxing"),
("11", "Harassment or bullying"),
("12", "Payment fraud"),
("13", "Child safety violation"),
("14", "Billing dispute or abuse"),
("15", "Unsolicited explicit content"),
("16", "Graphic violence"),
("17", "Ban evasion"),
("18", "Token or credential scam"),
("19", "Inactivity"),
("20", "Hate speech or extremist content"),
("21", "Malicious links or malware distribution"),
("22", "Impersonation or fake identity"),
];
pub struct CurrentBan<'a> {
pub entry: &'a AuditLogEntry,
pub notes: Vec<&'a AuditLogEntry>,
@@ -749,164 +725,3 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
});
})();
"#;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn user(extra: Value) -> AdminUser {
let mut value =
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
target.extend(fields.clone());
}
serde_json::from_value(value).expect("valid admin user")
}
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1400000000000000001",
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
"action": action,
"target_id": "1500000000000000001",
"target_type": "user",
"audit_log_reason": reason,
"metadata": metadata,
"created_at": "2026-09-01T10:00:00.000Z"
}))
.expect("valid audit log entry")
}
#[test]
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
let target = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3,
"deletion_public_reason": "Spam",
"deletion_audit_log_reason": "Report batch 12",
"deletion_scheduled_by": "1400000000000000001",
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup =
deletion_card("/admin", &target, "csrf", Some(&scheduler), 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();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup =
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_offers_no_email_and_sends_none() {
let pending = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3
}));
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let forms = [
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
];
for markup in &forms {
assert!(!markup.contains("Email the user"));
assert!(!markup.contains(r#"name="notify_user" value="true""#));
}
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
assert!(!forms[1].contains("notify_user"));
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
assert!(markup.contains("?action=unban&amp;tab=moderation"));
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
assert!(markup.contains(
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
));
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")));
}
}
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::templates::components::report_category::{report_status, report_type};
use crate::{
api::types::{ReportEntry, SearchReportsResponse},
config::AdminConfig,
@@ -119,47 +123,13 @@ fn report_section(
}
@if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" {
@if has_previous {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })}
class="inline-flex items-center rounded-md border \
border-neutral-300 bg-white px-3 py-2 text-sm \
font-medium text-neutral-700 hover:bg-neutral-50" {
"Previous"
}
}
@if let Some(next_page) = next_page {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })}
class="inline-flex items-center rounded-md border \
border-neutral-300 bg-white px-3 py-2 text-sm \
font-medium text-neutral-700 hover:bg-neutral-50" {
"Next"
}
}
}
(pager_with_missing(has_previous.then(|| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {current_page - 1} else {sent_page}, if kind == "received" {current_page - 1} else {received_page})), None, next_page.map(|page| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {page} else {sent_page}, if kind == "received" {page} else {received_page})), true))
}
}
}))
}
}
fn format_report_type(report_type: i32) -> &'static str {
match report_type {
0 => "Message",
1 => "User",
2 => "Guild",
_ => "Unknown",
}
}
pub(crate) fn format_status(status: i32) -> &'static str {
match status {
0 => "Pending",
1 => "Resolved",
_ => "Unknown",
}
}
pub(crate) fn tagged_user(
global_name: Option<&str>,
username: &str,
@@ -252,7 +222,7 @@ fn type_and_category_lines(report: &ReportEntry) -> Markup {
.zip(reason_label)
.is_some_and(|(category, label)| reason_repeats_category(category, label));
html! {
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
div class="text-neutral-500 text-xs" { (report_type(report.report_type).0) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
(report_category(category))
@@ -319,7 +289,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
}
}
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
(format_status(report.status))
(report_status(report.status).0)
}
}
}
@@ -375,168 +345,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}");
}
}
+8 -42
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::pagination::pager_with_missing;
use crate::{
api::types::AdminUser,
config::AdminConfig,
@@ -422,26 +424,12 @@ fn render_users_table(
fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> Markup {
let next_page = params.page.checked_add(1).filter(|_| has_more);
html! {
div class="mt-4 flex items-center justify-between" {
@if params.page > 0 {
a href=(users_url(base, params, params.page - 1))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"< Previous"
}
} @else {
span {}
}
@if let Some(next_page) = next_page {
a href=(users_url(base, params, next_page))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Next >"
}
} @else {
span {}
}
}
}
pager_with_missing(
(params.page > 0).then(|| users_url(base, params, params.page - 1)),
None,
next_page.map(|page| users_url(base, params, page)),
true,
)
}
fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
@@ -459,25 +447,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"));
}
}
Loaded 100 of 4401 files, more files were not shown because too many files have changed in this diff. Show more