Compare 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
3079 changed files with 53426 additions and 177852 deletions

No files matched your search

-6
View File
@@ -165,11 +165,6 @@ services:
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
@@ -360,7 +355,6 @@ volumes:
fluxer-api-geoip-node-modules:
fluxer-api-http-client-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
View File
@@ -48,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/
+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 }}
+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 -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
+20 -112
View File
@@ -35,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/**') }}
- 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/**') }}
save: 'true'
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -90,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/**') }}
- 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
@@ -118,21 +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',
'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: |
@@ -140,21 +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',
'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
@@ -274,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
@@ -299,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/**') }}
- 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
@@ -380,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/**') }}
- 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
@@ -408,21 +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',
'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: |
@@ -430,21 +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',
'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
-3
View File
@@ -42,9 +42,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/
Generated
+19 -63
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,7 +1724,9 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"hex",
"hyper",
"hyper-util",
"libc",
@@ -1792,7 +1782,6 @@ dependencies = [
"bytes",
"cc",
"clap",
"criterion",
"flate2",
"fluxer_common",
"futures-util",
@@ -1803,6 +1792,7 @@ dependencies = [
"http-body-util",
"hyper",
"libc",
"memmap2",
"moka",
"parking_lot",
"percent-encoding",
@@ -1834,7 +1824,6 @@ dependencies = [
"anyhow",
"base64 0.23.1",
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
@@ -1859,6 +1848,7 @@ dependencies = [
"base64 0.23.1",
"clap",
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"p256",
@@ -1899,6 +1889,7 @@ dependencies = [
"bytes",
"chrono",
"deadpool-postgres",
"fluxer_common",
"futures",
"libc",
"rmp-serde",
@@ -2018,12 +2009,16 @@ dependencies = [
"base64 0.23.1",
"hex",
"hmac 0.13.0",
"http 1.5.0",
"libc",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
@@ -2892,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"
@@ -3304,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"
@@ -3701,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"
+1 -1
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
-2
View File
@@ -70,8 +70,6 @@ workloads:
image:
name: fluxer-app-proxy-self-hosted
replicas: 1
env:
RELEASE_CHANNEL: stable
resources:
requests:
cpu: 50m
+9 -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
@@ -630,7 +636,8 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
# app-proxy index upstream and manifest scope.
# app-proxy discovery refresh, index upstream and manifest scope.
#DISCOVERY_REFRESH_INTERVAL_MS=60000
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
+10
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:-}
@@ -660,6 +661,15 @@ services:
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:-}
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 -17
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",
@@ -17514,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.",
+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
+5 -2
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, 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
+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 -130
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,154 +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 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::*;
-5
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>,
+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)]
+12 -18
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};
@@ -367,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?;
@@ -439,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,
@@ -458,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
}
@@ -589,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()
+8 -6
View File
@@ -1,5 +1,9 @@
// 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};
@@ -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,
@@ -110,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),
@@ -136,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 -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
}
+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";
}
}
}
@@ -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)] = &[
@@ -51,3 +60,60 @@ pub fn report_category(category: &str) -> Markup {
}
}
}
pub(crate) fn report_status(status: i32) -> (&'static str, BadgeVariant) {
match status {
0 => ("Pending", BadgeVariant::Warning),
1 => ("Resolved", BadgeVariant::Success),
_ => ("Unknown", BadgeVariant::Default),
}
}
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),
}
}
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
}
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))
}
},
}
}
+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" {
+4 -18
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))
}
}
}
@@ -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 {
@@ -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,23 +260,3 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[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""#));
}
}
@@ -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 {
@@ -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()
@@ -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 \
@@ -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 {
@@ -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" {
@@ -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)) }
}
}
}
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::report_category::{
message_lookup_href, report_status, report_type, reported_guild_icon,
};
use crate::{
acl,
api::{
@@ -22,7 +26,7 @@ use crate::{
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button,
opt_out_checkbox, select_chevron,
},
media::{guild_icon_url, initials, user_avatar_url},
media::user_avatar_url,
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
nsfw_indicators::{
@@ -50,11 +54,7 @@ pub struct LiveProfile {
}
fn status_badge(status: i32) -> Markup {
let (label, variant) = match status {
0 => ("Pending", BadgeVariant::Warning),
1 => ("Resolved", BadgeVariant::Success),
_ => ("Unknown", BadgeVariant::Default),
};
let (label, variant) = report_status(status);
html! {
span class="inline-flex w-fit self-start" {
(badge(label, variant))
@@ -62,15 +62,6 @@ fn status_badge(status: i32) -> Markup {
}
}
fn report_type_label(report_type: i32) -> &'static str {
match report_type {
0 => "Message",
1 => "User",
2 => "Guild",
_ => "Unknown",
}
}
fn reporter_label(report: &ReportEntry) -> String {
if let Some(tag) = &report.reporter_tag {
return tag.to_owned();
@@ -116,7 +107,7 @@ fn basic_info_section(config: &AdminConfig, auth: &AuthContext, report: &ReportE
div class="grid grid-cols-1 sm:grid-cols-2 gap-4" {
(data_field_mono("Report ID", &report.report_id))
(data_field_text("Reported At", &format_admin_timestamp(&report.reported_at)))
(data_field_text("Type", report_type_label(report.report_type)))
(data_field_text("Type", report_type(report.report_type).0))
(category_field(report))
(reason_field(report))
@if let Some(ref reporter_id) = report.reporter_id {
@@ -237,18 +228,6 @@ fn answer_steps(flow: &ReportFlowAnswersEntry) -> Markup {
}
}
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 snapshot_note() -> Markup {
html! {
span class="text-neutral-500 text-xs italic" { "(at time of report)" }
@@ -484,30 +463,6 @@ fn reported_guild_field(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 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 additional_info_section(report: &ReportEntry) -> Markup {
html! {
@if let Some(ref info) = report.additional_info {
@@ -1182,7 +1137,7 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
div class="grid grid-cols-1 sm:grid-cols-2 gap-4" {
(data_field_mono("Report ID", &report.report_id))
(data_field_text("Reported At", &format_admin_timestamp(&report.reported_at)))
(data_field_text("Type", report_type_label(report.report_type)))
(data_field_text("Type", report_type(report.report_type).0))
(category_field(report))
(reason_field(report))
@if let Some(ref reporter_id) = report.reporter_id {
@@ -1195,14 +1150,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_form_shares_the_comment_by_default_and_says_the_reporter_is_always_told() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
}
}
@@ -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>) {
@@ -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"
}
@@ -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,69 +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")
}
#[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""#));
}
}
@@ -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"))
@@ -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)
}
}
}
+8 -20
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 {
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::form::form_field_with_id as form_field;
use crate::{
api::types::{VoiceRegion, VoiceServer},
config::AdminConfig,
@@ -257,28 +259,3 @@ fn create_region_form(config: &AdminConfig, csrf_token: &str) -> Markup {
}
})
}
fn form_field(
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";
}
}
}
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::form::form_field_with_id;
use crate::{
api::types::VoiceServer,
config::AdminConfig,
@@ -142,31 +144,6 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
})
}
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";
}
}
}
fn form_field_with_helper(
label: &str,
id: &str,
+13
View File
@@ -25,3 +25,16 @@ pub fn user_tag(username: &str, discriminator: &str, is_bot: bool) -> String {
username.to_owned()
}
}
pub fn display_with_tag(
global_name: Option<&str>,
username: &str,
discriminator: &str,
is_bot: bool,
) -> String {
let tag = user_tag(username, discriminator, is_bot);
match global_name.filter(|name| !name.trim().is_empty()) {
Some(name) => format!("{name} ({tag})"),
None => tag,
}
}
+2 -550
View File
@@ -3,69 +3,6 @@
use fluxer_admin::api::generated::types as generated_types;
use fluxer_admin::api::types;
#[test]
fn deserialize_admin_users_me_response() {
let json = r#"{
"user": {
"id": "1130650140672000000",
"username": "Hampus",
"discriminator": 0,
"global_name": "Hampus",
"bot": false,
"system": false,
"flags": "2308095358783193091",
"premium_flags": 2,
"avatar": "563de430",
"banner": "bd221c57",
"bio": "For support, please contact [email protected].",
"pronouns": "he/him",
"accent_color": 2631308,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"date_of_birth": "2003-02-25",
"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": 1,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["super_admin"],
"traits": ["beta_tester"],
"has_totp": true,
"authenticator_types": [1],
"last_active_at": "2026-05-26T15:00:00.000Z",
"last_active_ip": "1.2.3.4",
"last_active_ip_reverse": "example.com",
"last_active_location": "Stockholm, SE"
}
}"#;
let resp: types::AdminUserMeResponse = serde_json::from_str(json).unwrap();
let user = resp.user;
assert_eq!(user.id, "1130650140672000000");
assert_eq!(user.username, "Hampus");
assert_eq!(user.discriminator, "0000");
assert_eq!(user.flags, 2308095358783193091);
assert_eq!(user.premium_flags, 2);
assert!(!user.bot);
assert!(!user.system);
assert_eq!(user.acls, vec!["super_admin"]);
assert_eq!(user.traits, vec!["beta_tester"]);
assert_eq!(user.premium_type, Some(2));
assert!(user.has_totp);
assert_eq!(user.last_active_ip.as_deref(), Some("1.2.3.4"));
}
#[test]
fn deserialize_flags_as_string_and_number() {
let json_str = r#"{
@@ -136,122 +73,6 @@ fn deserialize_discriminator_int_and_string() {
assert_eq!(resp4.user.discriminator, "0042");
}
#[test]
fn deserialize_search_users_response() {
let json = r#"{
"users": [
{
"id": "1508576042312688531",
"username": "test",
"discriminator": 4363,
"global_name": null,
"bot": true,
"system": false,
"flags": "0",
"premium_flags": 0,
"avatar": null,
"banner": null,
"bio": null,
"pronouns": null,
"accent_color": null,
"email": null,
"email_verified": false,
"email_bounced": false,
"date_of_birth": null,
"locale": null,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
"authenticator_types": [],
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
}
],
"total": 42
}"#;
let resp: types::SearchUsersResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 42);
assert_eq!(resp.users.len(), 1);
assert_eq!(resp.users[0].username, "test");
assert_eq!(resp.users[0].discriminator, "4363");
assert!(resp.users[0].bot);
}
#[test]
fn deserialize_search_guilds_response() {
let json = r#"{
"guilds": [
{
"id": "1427764661718740994",
"name": "Fluxer Testers",
"features": ["ANIMATED_BANNER", "VERIFIED"],
"owner_id": "1489329094902315533",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"icon": "de44253f",
"banner": "048c048f",
"member_count": 715,
"nsfw_level": 0
}
],
"total": 6723
}"#;
let resp: types::SearchGuildsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 6723);
assert_eq!(resp.guilds.len(), 1);
assert_eq!(resp.guilds[0].name, "Fluxer Testers");
assert_eq!(resp.guilds[0].member_count, 715);
assert_eq!(resp.guilds[0].features.len(), 2);
}
#[test]
fn deserialize_audit_logs_response() {
let json = r#"{
"logs": [
{
"log_id": "1508822460457747580",
"admin_user_id": "1130650140672000000",
"target_type": "user",
"target_id": "1130958221824557056",
"action": "list_user_sessions",
"access": "read",
"audit_log_reason": null,
"metadata": {"session_count": "3"},
"created_at": "2026-05-26T13:21:47.138Z"
}
],
"total": 94623
}"#;
let resp: types::AuditLogsListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 94623);
assert_eq!(resp.logs.len(), 1);
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
assert_eq!(resp.logs[0].action, "list_user_sessions");
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
assert_eq!(resp.logs[0].target_type, "user");
assert!(resp.logs[0].audit_log_reason.is_none());
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
}
#[test]
fn deserialize_guild_audit_logs_with_unknown_keys() {
let json = r#"{
@@ -500,7 +321,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"port": 587,
"username": "fluxer",
"password_set": true,
"secure": true
"tls_mode": null,
"effective_tls_mode": "starttls"
},
"disable_new_ip_authorization": false,
"effective_disable_new_ip_authorization": false
@@ -713,70 +535,6 @@ fn serialize_push_relay_update_omits_an_unset_consent() {
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
"reports": [
{
"report_id": "1508849882800552806",
"reporter_id": "1474002886188635356",
"reporter_tag": "user#2602",
"reporter_username": "user",
"reporter_global_name": null,
"reporter_discriminator": "2602",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-05-26T15:10:45.134Z",
"status": 0,
"report_type": 0,
"category": "nsfw_violation",
"additional_info": null,
"reported_user_id": "1461557793540882622",
"reported_user_tag": "ReportedUser#4331",
"reported_user_username": "ReportedUser",
"reported_user_global_name": "ReportedUser",
"reported_user_discriminator": "4331",
"reported_user_avatar_hash": "75c675b2",
"reported_guild_id": null,
"reported_guild_name": null,
"reported_guild_icon_hash": null,
"reported_message_id": "1508564782082866178",
"reported_channel_id": "1487475053377430086",
"reported_channel_name": null,
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": null,
"reported_guild_nsfw": null,
"reported_guild_content_warning_level": null,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": null,
"reported_channel_effective_content_warning_level": null,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"mutual_dm_channel_id": null,
"message_context": []
}
],
"total": 1,
"offset": 0,
"limit": 25
}"#;
let resp: types::SearchReportsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 1);
assert_eq!(resp.reports.len(), 1);
assert_eq!(resp.reports[0].report_id, "1508849882800552806");
assert_eq!(resp.reports[0].status, 0);
assert_eq!(resp.reports[0].category.as_deref(), Some("nsfw_violation"));
assert!(resp.reports[0].reported_guild_icon_hash.is_none());
}
fn report_json(extra: serde_json::Value) -> serde_json::Value {
let mut report = serde_json::json!({
"report_id": "1556008115701286089",
@@ -1472,312 +1230,6 @@ fn deserialize_report_reason_list() {
assert!(lenient.reasons[0].legacy_category_message.is_none());
}
#[test]
fn deserialize_lookup_guild_response() {
let json = r#"{
"guild": {
"id": "123",
"owner_id": "456",
"owner_username": "admin",
"owner_global_name": "Admin",
"owner_discriminator": "0001",
"name": "Test Guild",
"vanity_url_code": null,
"icon": null,
"banner": null,
"splash": null,
"embed_splash": null,
"features": [],
"verification_level": 0,
"mfa_level": 0,
"nsfw_level": 0,
"explicit_content_filter": 0,
"default_message_notifications": 0,
"afk_channel_id": null,
"afk_timeout": 300,
"system_channel_id": null,
"system_channel_flags": 0,
"rules_channel_id": null,
"disabled_operations": 0,
"member_count": 100,
"channels": [],
"roles": []
}
}"#;
let resp: types::LookupGuildResponse = serde_json::from_str(json).unwrap();
let guild = resp.guild.unwrap();
assert_eq!(guild.id, "123");
assert_eq!(guild.name, "Test Guild");
assert_eq!(guild.member_count, 100);
}
#[test]
fn deserialize_lookup_guild_null() {
let json = r#"{"guild": null}"#;
let resp: types::LookupGuildResponse = serde_json::from_str(json).unwrap();
assert!(resp.guild.is_none());
}
#[test]
fn deserialize_lookup_application_response() {
let json = r#"{
"application": {
"id": "111",
"name": "My Bot",
"owner_user_id": "222",
"owner_username": "dev",
"owner_global_name": null,
"owner_discriminator": "0001",
"bot_user_id": "333",
"bot_username": "MyBot",
"bot_global_name": null,
"bot_discriminator": "0001",
"bot_is_public": true,
"bot_require_code_grant": false,
"oauth2_redirect_uris": ["https://example.com/cb"],
"has_client_secret": true,
"has_bot_token": true,
"bot_token_preview": "...xyz",
"bot_token_created_at": "2025-01-01T00:00:00.000Z",
"client_secret_created_at": "2025-01-01T00:00:00.000Z",
"version": 1
}
}"#;
let resp: types::LookupApplicationResponse = serde_json::from_str(json).unwrap();
let app = resp.application.unwrap();
assert_eq!(app.id, "111");
assert_eq!(app.name, "My Bot");
assert!(app.bot_is_public);
}
#[test]
fn deserialize_refresh_search_index_response() {
let json = r#"{"success": true, "job_id": "abc123"}"#;
let resp: types::RefreshSearchIndexResponse = serde_json::from_str(json).unwrap();
assert!(resp.success);
assert_eq!(resp.job_id, "abc123");
}
#[test]
fn deserialize_ban_check_response() {
let json = r#"{"banned": true}"#;
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
assert!(resp.banned);
}
#[test]
fn deserialize_ban_check_response_with_expiry() {
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
assert!(resp.banned);
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
}
#[test]
fn deserialize_codes_response() {
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
let resp: types::CodesResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.codes.len(), 2);
}
#[test]
fn deserialize_user_mutation_response() {
let json = r#"{
"user": {
"id": "1", "username": "updated", "discriminator": 42,
"global_name": null, "bot": false, "system": false,
"flags": "1", "premium_flags": 0, "avatar": null, "banner": null,
"bio": null, "pronouns": null, "accent_color": null, "email": null,
"email_verified": false, "email_bounced": false,
"date_of_birth": null, "locale": null,
"premium_type": null, "premium_since": null, "premium_until": null,
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
"temp_banned_until": null,
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
"last_active_at": null, "last_active_ip": null,
"last_active_ip_reverse": null, "last_active_location": null
}
}"#;
let resp: types::UserMutationResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.user.username, "updated");
assert_eq!(resp.user.discriminator, "0042");
assert_eq!(resp.user.flags, 1);
}
#[test]
fn deserialize_guild_update_response() {
let json = r#"{
"guild": {
"id": "123",
"name": "Updated Guild",
"features": ["VERIFIED"],
"owner_id": "456",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"icon": null,
"banner": null,
"member_count": 50,
"nsfw_level": 0
}
}"#;
let resp: types::GuildUpdateResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.guild.name, "Updated Guild");
assert_eq!(resp.guild.member_count, 50);
}
#[test]
fn deserialize_list_guild_members_response() {
let json = r#"{
"members": [
{
"user": {
"id": "111",
"username": "member1",
"discriminator": 1234,
"global_name": "Member One",
"avatar": null,
"bot": false
},
"nick": "Memb",
"joined_at": "2025-01-01T00:00:00.000Z",
"roles": ["222", "333"]
}
],
"total": 1,
"limit": 50,
"offset": 0
}"#;
let resp: types::ListGuildMembersResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 1);
assert_eq!(resp.members[0].user.username, "member1");
assert_eq!(resp.members[0].user.discriminator, "1234");
assert_eq!(resp.members[0].roles.len(), 2);
}
#[test]
fn deserialize_list_user_sessions_response() {
let json = r#"{
"sessions": [
{
"session_id_hash": "abc123",
"created_at": "2025-01-01T00:00:00.000Z",
"approx_last_used_at": "2025-06-01T00:00:00.000Z",
"client_ip": "1.2.3.4",
"client_ip_reverse": "host.example.com",
"client_os": "Windows 11",
"client_platform": "desktop",
"client_location": "New York, US"
}
]
}"#;
let resp: types::ListUserSessionsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.sessions.len(), 1);
assert_eq!(resp.sessions[0].client_ip, "1.2.3.4");
}
#[test]
fn deserialize_webauthn_credentials_response() {
let json = r#"[
{
"id": "credential-a",
"name": "YubiKey",
"created_at": "2026-05-26T12:00:00.000Z",
"last_used_at": null,
"rp_id": "fluxer.com"
},
{
"id": "credential-b",
"name": "Touch ID",
"created_at": "2026-05-25T12:00:00.000Z",
"last_used_at": "2026-05-26T13:00:00.000Z"
}
]"#;
let credentials: types::WebAuthnCredentialListResponse = serde_json::from_str(json).unwrap();
assert_eq!(credentials.len(), 2);
assert_eq!(credentials[0].name, "YubiKey");
assert!(credentials[0].last_used_at.is_none());
assert_eq!(
credentials[1].last_used_at.as_deref(),
Some("2026-05-26T13:00:00.000Z")
);
}
#[test]
fn deserialize_list_user_relationships_response() {
let json = r#"{
"friends": [
{
"target_user_id": "111",
"category": "friend",
"nickname": null,
"since": "2025-01-01T00:00:00.000Z",
"target": {
"id": "111",
"username": "friend1",
"discriminator": "0042",
"global_name": "Friend One",
"avatar": null
}
}
],
"incoming_requests": [],
"outgoing_requests": [],
"blocked": []
}"#;
let resp: types::ListUserRelationshipsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.friends.len(), 1);
assert_eq!(
resp.friends[0].target.as_ref().unwrap().discriminator,
"0042"
);
}
#[test]
fn deserialize_create_admin_api_key_response() {
let json = r#"{
"key_id": "k_123",
"key": "flx_secret_abc",
"name": "My Key",
"created_at": "2026-01-01T00:00:00.000Z",
"expires_at": null,
"acls": ["super_admin"]
}"#;
let resp: types::CreateAdminApiKeyResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.key_id, "k_123");
assert_eq!(resp.key, "flx_secret_abc");
assert_eq!(resp.acls, vec!["super_admin"]);
}
#[test]
fn deserialize_list_admin_api_key_entry() {
let json = r#"{
"key_id": "k_123",
"name": "My Key",
"created_at": "2026-01-01T00:00:00.000Z",
"last_used_at": "2026-05-01T00:00:00.000Z",
"expires_at": null,
"created_by_user_id": "1130650140672000000",
"acls": ["super_admin", "reports"]
}"#;
let resp: types::ListAdminApiKeyEntry = serde_json::from_str(json).unwrap();
assert_eq!(resp.key_id, "k_123");
assert_eq!(resp.created_by_user_id, "1130650140672000000");
assert_eq!(resp.acls.len(), 2);
}
#[test]
fn account_identity_lock_is_unknown_when_the_api_omits_it() {
let identity: types::AccountIdentityConfigResponse =
+1 -746
View File
@@ -10,7 +10,6 @@ use axum::{
routing,
};
use fluxer_admin::{
api::generated::types as generated_types,
build_router,
config::{AdminConfig, ProxyConfig},
session,
@@ -21,7 +20,6 @@ use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "htmx-acceptance-test-secret";
const ADMIN_API_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForAcceptance";
struct TestApp {
router: Router,
@@ -141,184 +139,6 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
assert_eq!(received.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn instance_config_legal_form_round_trips_the_guidelines_url() {
let patches = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = patches.clone();
let configured = || {
let mut config = instance_config();
config["self_hosted"] = json!(true);
config["app_public"] = json!({
"branding": {"product_name": "Fluxer", "premium_product_name": "Premium"},
"setup": {"configured": true},
"legal": {
"terms_url": "https://example.com/terms",
"privacy_url": null,
"guidelines_url": "https://example.com/community-guidelines"
},
"registration": {"collect_date_of_birth": true}
});
config
};
let api = Router::new()
.route(
"/admin/instance/config",
routing::get(move || async move { json_response(configured()) }).patch(
move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body);
json_response(configured())
}
},
),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await;
assert!(
page.contains(r#"id="app_guidelines_url" name="app_guidelines_url" value="https://example.com/community-guidelines""#),
"{page}"
);
assert!(page.contains("Community Guidelines URL"), "{page}");
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(
&page,
"/instance-config?action=update_app_legal",
&csrf_token,
);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
for form in [
format!(
"_csrf={csrf_token}&app_terms_url=https%3A%2F%2Fexample.com%2Fterms&app_privacy_url=&app_guidelines_url=https%3A%2F%2Frules.example.org%2Fguidelines"
),
format!("_csrf={csrf_token}&app_terms_url=&app_privacy_url=&app_guidelines_url="),
] {
let (status, _, body) = post_form_with_headers(
&app,
"/instance-config?action=update_app_legal",
&[("Cookie", &cookie)],
&form,
)
.await;
assert!(
status.is_redirection() || status.is_success(),
"{status} {body}"
);
}
let patches = patches.lock().unwrap();
assert_eq!(
*patches,
vec![
json!({"app_public": {"legal": {
"terms_url": "https://example.com/terms",
"privacy_url": null,
"guidelines_url": "https://rules.example.org/guidelines"
}}}),
json!({"app_public": {"legal": {
"terms_url": null,
"privacy_url": null,
"guidelines_url": null
}}}),
]
);
}
#[tokio::test]
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/reports/1800000000000000001",
routing::patch(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body);
json_response(json!({
"report_id": "1800000000000000001",
"status": 1,
"resolved_at": "2026-05-26T12:03:00.000Z",
"public_comment": null
}))
}
})
.get(|| async { json_response(searched_report()) }),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(&page, "/reports/1800000000000000001/resolve", &csrf_token);
assert!(
page.contains(r#"<select id="resolution" name="resolution" required"#),
"{page}"
);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let post = |body: String| {
let app = &app;
let cookie = cookie.clone();
async move {
let (status, headers, text) = post_form_with_headers(
app,
"/reports/1800000000000000001/resolve",
&[
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", &cookie),
],
&body,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
.to_owned()
}
};
for (value, label) in [
("actioned", "Action taken"),
("no_violation", "No violation"),
("duplicate", "Duplicate"),
] {
let toast = post(format!("_csrf={csrf_token}&resolution={value}")).await;
assert!(toast.contains("success"), "{toast}");
assert!(
toast.contains(&format!("Report resolved: {label}")),
"{toast}"
);
}
for refused in [
format!("_csrf={csrf_token}&public_comment=Looks+fine"),
format!("_csrf={csrf_token}&resolution=&public_comment=Looks+fine"),
format!("_csrf={csrf_token}&resolution=auto_resolved"),
] {
let toast = post(refused).await;
assert!(toast.contains("error"), "{toast}");
assert!(toast.contains("Choose a resolution"), "{toast}");
}
let toast = post(format!(
"_csrf={csrf_token}&resolution=actioned&public_comment=Removed&notify_reporter_present=1"
))
.await;
assert!(toast.contains("Report resolved: Action taken"), "{toast}");
let received = received.lock().unwrap();
assert_eq!(
*received,
vec![
json!({"status": "resolved", "resolution": "actioned", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "no_violation", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "duplicate", "notify_reporter": true}),
json!({"status": "resolved", "resolution": "actioned", "public_comment": "Removed", "notify_reporter": false}),
]
);
}
#[tokio::test]
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
@@ -558,138 +378,14 @@ async fn spawn_mock_api(api: Router) -> String {
}
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
match (method, uri.path()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
(Method::POST, "/admin/api-keys") => json_response(json!({
"key_id": "1900000000000000001",
"key": ADMIN_API_KEY_SECRET,
"name": "Acceptance key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": null,
"acls": ["*"]
})),
(Method::GET, "/admin/users") => {
json_response(json!({ "users": [searched_user()], "total": 1 }))
}
(Method::GET, "/admin/users/1500000000000000001") => {
json_response(json!({ "users": [searched_user()] }))
}
(Method::POST, "/admin/users/1500000000000000001/password-reset") => {
StatusCode::NO_CONTENT.into_response()
}
(Method::GET, "/admin/guilds") => {
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
}
(Method::GET, "/admin/guilds/1600000000000000001") => {
json_response(json!({ "guild": searched_guild_detail() }))
}
(Method::GET, "/admin/applications") => {
json_response(json!({ "applications": [searched_application()] }))
}
(Method::GET, "/admin/reports")
if query_value(&uri, "reason").as_deref() == Some("csam") =>
{
json_response(json!({
"reports": [flow_report()],
"total": 60,
"offset": 0,
"limit": 25
}))
}
(Method::GET, "/admin/reports")
if query_value(&uri, "category").as_deref() == Some("future_value") =>
{
json_response(json!({
"reports": [future_category_report()],
"total": 60,
"offset": 0,
"limit": 25
}))
}
(Method::GET, "/admin/reports")
if query_value(&uri, "reported_webhook_id").as_deref() == Some(WEBHOOK_ID) =>
{
json_response(json!({
"reports": [webhook_report()],
"total": 60,
"offset": 0,
"limit": 25
}))
}
(Method::GET, "/admin/reports") => json_response(
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
),
(Method::GET, "/admin/report-reasons") => json_response(report_reasons()),
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
(Method::GET, "/admin/reports/1800000000000000003") => json_response(flow_report()),
(Method::GET, "/admin/reports/1800000000000000004") => json_response(webhook_report()),
(Method::GET, "/admin/reports/1800000000000000005") => {
json_response(future_status_report())
}
(Method::GET, "/admin/reports/1800000000000000002") => {
json_response(searched_message_report())
}
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
"report_id": "1800000000000000001",
"status": 1,
"resolved_at": "2026-05-26T12:03:00.000Z",
"public_comment": "done"
})),
(Method::GET, "/admin/jobs") => {
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
}
(Method::GET, "/admin/jobs/1900000000000000001") => {
json_response(json!({ "job": searched_job() }))
}
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
"registration_url": registration_url_fixture(),
"code": "11111111-1111-4111-8111-111111111111",
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
})),
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
json_response(instance_config_without_registration_urls())
}
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
json_response(instance_config_without_pending_registrations())
}
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
(Method::GET, "/admin/audit-logs") => {
let access = uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "access")
.map(|(_, value)| value.into_owned())
});
let logs = [
audit_log_entry("1900000000000000101", "get_user", "read"),
audit_log_entry("1900000000000000102", "temp_ban", "write"),
]
.into_iter()
.filter(|entry| {
access
.as_deref()
.is_none_or(|access| entry.access.to_string() == access)
})
.collect::<Vec<_>>();
json_response(json!({ "total": logs.len(), "logs": logs }))
}
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
fn query_value(uri: &Uri, name: &str) -> Option<String> {
uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == name)
.map(|(_, value)| value.into_owned())
})
}
fn json_response(value: Value) -> Response {
Json(value).into_response()
}
@@ -700,10 +396,6 @@ fn admin_user() -> Value {
user
}
fn searched_user() -> Value {
user("1500000000000000001", "SearchedUser")
}
fn user(id: &str, username: &str) -> Value {
json!({
"id": id,
@@ -748,111 +440,6 @@ fn user(id: &str, username: &str) -> Value {
})
}
fn searched_guild() -> generated_types::GuildAdminResponse {
serde_json::from_value(json!({
"id": "1600000000000000001",
"name": "Searched Guild",
"icon": null,
"banner": null,
"owner_id": "1500000000000000001",
"owner_username": "SearchedUser",
"owner_global_name": "SearchedUser",
"owner_discriminator": "0001",
"member_count": 12,
"features": ["COMMUNITY"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": 0,
"content_warning_text": null
}))
.expect("guild search fixture must match the generated response contract")
}
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
serde_json::from_value(json!({
"id": "1600000000000000001",
"owner_id": "1500000000000000001",
"owner_username": "SearchedUser",
"owner_global_name": "SearchedUser",
"owner_discriminator": "0001",
"name": "Searched Guild",
"vanity_url_code": null,
"icon": null,
"banner": null,
"splash": null,
"embed_splash": null,
"features": ["COMMUNITY"],
"verification_level": 1,
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 0,
"default_message_notifications": 0,
"afk_channel_id": null,
"afk_timeout": 0,
"system_channel_id": null,
"system_channel_flags": 0,
"rules_channel_id": null,
"disabled_operations": 0,
"member_count": 12,
"channels": [],
"roles": []
}))
.expect("guild detail fixture must match the generated response contract")
}
fn audit_log_entry(
log_id: &str,
action: &str,
access: &str,
) -> generated_types::AdminAuditLogResponseSchema {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1500000000000000000",
"admin_user": null,
"target_type": "user",
"target_id": "1500000000000000001",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"action": action,
"access": access,
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-16T12:00:00.000Z"
}))
.expect("audit log fixture must match the generated response contract")
}
fn searched_application() -> Value {
json!({
"id": "1700000000000000001",
"name": "Mock Application",
"owner_user_id": "1500000000000000001",
"owner_username": "SearchedUser",
"owner_global_name": "SearchedUser",
"owner_discriminator": "0001",
"bot_user_id": null,
"bot_username": null,
"bot_global_name": null,
"bot_discriminator": null,
"bot_is_public": true,
"bot_require_code_grant": false,
"oauth2_redirect_uris": [],
"has_client_secret": false,
"has_bot_token": false,
"bot_token_preview": null,
"bot_token_created_at": null,
"client_secret_created_at": null,
"version": 1
})
}
fn searched_report() -> Value {
json!({
"report_id": "1800000000000000001",
@@ -868,21 +455,6 @@ fn searched_report() -> Value {
})
}
fn future_category_report() -> Value {
let mut report = searched_report();
report["report_id"] = json!("1800000000000000006");
report["category"] = json!("future_value");
report
}
fn future_status_report() -> Value {
let mut report = searched_report();
report["report_id"] = json!("1800000000000000005");
report["status"] = json!(7);
report["report_type"] = json!(9);
report
}
fn report_reasons() -> Value {
json!({
"reasons": [
@@ -906,323 +478,6 @@ fn report_reasons() -> Value {
})
}
fn flow_report() -> Value {
json!({
"report_id": "1800000000000000003",
"reporter_id": "1500000000000000000",
"reporter_tag": "AdminUser#0001",
"reported_at": "2026-05-26T12:00:00.000Z",
"status": 0,
"report_type": 0,
"category": "child_safety",
"additional_info": "Mock flow report details",
"reported_user_id": "1500000000000000001",
"reported_user_tag": "SearchedUser#0001",
"reported_message_id": "1800000000000001001",
"reported_channel_id": "1600000000000000101",
"reason": "csam",
"reason_label": "Child sexual abuse material",
"reason_highest_priority": true,
"reporter_good_faith_confirmed": true,
"flow": {
"revision_hash": "b7667e8b32c98c40",
"surface": "dsa",
"locale": "fr",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "abuse",
"option_label": "Abusive or harmful content",
"items": []
},
{
"screen_id": "private_info",
"screen_title": "What private information is shared?",
"option_id": null,
"option_label": null,
"items": [
{"id": "email", "label": "Email address"},
{"id": "phone", "label": "Phone number"}
]
},
{
"screen_id": "profile_intro",
"screen_title": "Report profile",
"option_id": null,
"option_label": null,
"items": []
}
]
}
})
}
const WEBHOOK_ID: &str = "1700000000000000500";
fn webhook_report() -> Value {
json!({
"report_id": "1800000000000000004",
"reporter_id": "1500000000000000000",
"reporter_tag": "AdminUser#0001",
"reported_at": "2026-05-26T12:00:00.000Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": null,
"reported_user_tag": null,
"reported_webhook_id": WEBHOOK_ID,
"reported_webhook_name": "Harbor Bulletin",
"reported_webhook_avatar_hash": "abc123",
"reported_webhook_default_name": "Harbor Hook",
"reported_webhook_default_avatar_hash": null,
"reported_webhook_type": 1,
"reported_webhook_application_id": null,
"reported_webhook_channel_id": "1600000000000000101",
"reported_webhook_guild_id": "1600000000000000001",
"reported_webhook_created_at": "2026-05-20T08:30:00.000Z",
"reported_webhook_creator_id": "1500000000000000002",
"reported_webhook_creator_tag": "HookOwner#0002",
"reported_webhook_creator_username": "HookOwner",
"reported_webhook_creator_global_name": null,
"reported_webhook_creator_discriminator": "0002",
"reported_webhook_creator_avatar_hash": null,
"reported_message_id": "1800000000000001004",
"reported_channel_id": "1600000000000000101",
"reported_channel_name": "general",
"reported_guild_id": "1600000000000000001",
"reported_guild_name": "Searched Guild",
"reason": "spam",
"reason_label": "Spam",
"reason_highest_priority": false,
"message_context": [
{
"id": "1800000000000001003",
"content": "Member message before the webhook",
"timestamp": "2026-05-26T12:00:30.000Z",
"author_id": "1500000000000000001",
"author_username": "SearchedUser",
"author_global_name": null,
"author_discriminator": "0001",
"author_avatar": null,
"webhook_id": null,
"channel_id": "1600000000000000101",
"attachments": []
},
{
"id": "1800000000000001004",
"content": "Webhook spam in drawer",
"timestamp": "2026-05-26T12:01:00.000Z",
"author_id": WEBHOOK_ID,
"author_username": "Harbor Bulletin",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": "abc123",
"webhook_id": WEBHOOK_ID,
"channel_id": "1600000000000000101",
"attachments": []
}
]
})
}
fn searched_message_report() -> Value {
json!({
"report_id": "1800000000000000002",
"reporter_id": "1500000000000000000",
"reporter_tag": "AdminUser#0001",
"reported_at": "2026-05-26T12:00:00.000Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": "Mock message report details",
"reported_user_id": "1500000000000000001",
"reported_user_tag": "SearchedUser#0001",
"reported_message_id": "1800000000000001001",
"reported_channel_id": "1600000000000000101",
"reported_channel_name": "general",
"message_context": [{
"id": "1800000000000001001",
"content": "Reported message in drawer",
"timestamp": "2026-05-26T12:01:00.000Z",
"author_id": "1500000000000000001",
"author_username": "SearchedUser",
"author_global_name": "SearchedUser",
"author_discriminator": "0001",
"author_avatar": null,
"channel_id": "1600000000000000101",
"attachments": []
}]
})
}
fn searched_job() -> Value {
json!({
"job_id": "1900000000000000001",
"task_type": "mockJobSync",
"status": "running",
"created_at": "2026-05-26T12:00:00.000Z",
"progress_current": 4,
"progress_total": 10,
"progress_message": null,
"error_message": null,
"started_at": "2026-05-26T12:00:05.000Z",
"completed_at": null,
"attempts": 1,
"max_attempts": 3,
"requested_by_user_id": "1500000000000000000",
"audit_log_reason": null,
"jet_stream_lane": null,
"jet_stream_seq": null,
"run_at": null,
"cancel_requested": false,
"context_link": null,
"payload": null,
"result": null
})
}
fn instance_config() -> Value {
json!({
"sso": {
"enabled": false,
"enforced": false,
"display_name": null,
"issuer": null,
"authorization_url": null,
"token_url": null,
"userinfo_url": null,
"jwks_url": null,
"client_id": null,
"client_secret_set": false,
"scope": null,
"allowed_domains": [],
"auto_provision": false,
"redirect_uri": "https://admin.example.test/oauth2_callback"
},
"gateway_rollout": {
"session_rollout_percentage": 100,
"session_rollout_mode": "modulo",
"guild_rollout_percentage": 100,
"rpc_request_timeout_ms": 5000,
"max_concurrent_session_starts": 16,
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"voice_p2p": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_country_codes": [],
"rollout_salt": "voice-p2p-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"max_participants": 2
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
},
"registration": registration_config(),
"self_hosted": false
})
}
fn registration_config() -> Value {
json!({
"mode": "approval",
"admin_registration_urls_enabled": true,
"urls": [registration_url_fixture()],
"pending_registrations": [pending_registration_fixture()]
})
}
fn registration_url_fixture() -> Value {
json!({
"id": "11111111-1111-4111-8111-111111111111",
"label": "Support batch",
"created_by_user_id": "1500000000000000000",
"created_at": "2026-05-26T12:00:00.000Z",
"expires_at": null,
"max_uses": 5,
"use_count": 0,
"revoked_at": null,
"approval_required": true,
"last_used_at": null,
"last_used_by_user_id": null
})
}
fn pending_registration_fixture() -> Value {
json!({
"user_id": "1500000000000000002",
"username": "PendingUser",
"discriminator": 0,
"global_name": "Pending User",
"email": "[email protected]",
"requested_at": "2026-05-26T12:10:00.000Z",
"registration_url_id": "11111111-1111-4111-8111-111111111111",
"client_ip": "203.0.113.24"
})
}
fn instance_config_without_pending_registrations() -> Value {
let mut config = instance_config();
config["registration"]["pending_registrations"] = json!([]);
config
}
fn instance_config_without_registration_urls() -> Value {
let mut config = instance_config();
config["registration"]["urls"] = json!([]);
config
}
fn limit_config() -> Value {
json!({
"limit_config": {
"traitDefinitions": [],
"rules": [{
"id": "default",
"filters": null,
"limits": { "maxGuilds": 100 }
}]
},
"limit_config_json": "{\"traitDefinitions\":[],\"rules\":[]}",
"self_hosted": false,
"defaults": {
"default": { "maxGuilds": 100 }
},
"metadata": {
"maxGuilds": {
"key": "maxGuilds",
"label": "Max Guilds",
"description": "Maximum guild memberships.",
"category": "account",
"scope": "user",
"isToggle": false,
"unit": null,
"min": 0,
"max": 1000
}
},
"categories": { "account": "Account" },
"limit_keys": ["maxGuilds"],
"bounds": null
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
host: "127.0.0.1".to_owned(),
+1 -4
View File
@@ -29,6 +29,7 @@
"@fluxer/ip_utils": "workspace:*",
"@fluxer/limits": "workspace:*",
"@fluxer/logger": "workspace:*",
"@fluxer/record_utils": "workspace:*",
"@fluxer/schema": "workspace:*",
"@fluxer/snowflake": "workspace:*",
"@hono/node-server": "catalog:",
@@ -44,7 +45,6 @@
"@pkgs/geoip": "workspace:*",
"@pkgs/http_client": "workspace:*",
"@pkgs/kv_client": "workspace:*",
"@pkgs/locale": "workspace:*",
"@pkgs/media_proxy_utils": "workspace:*",
"@pkgs/mime_utils": "workspace:*",
"@pkgs/nats": "workspace:*",
@@ -64,12 +64,10 @@
"hi-base32": "catalog:",
"hono": "catalog:",
"html-entities": "catalog:",
"idna-uts46-hx": "catalog:",
"ioredis": "catalog:",
"itty-time": "catalog:",
"jose": "catalog:",
"livekit-server-sdk": "catalog:",
"lodash": "catalog:",
"maxmind": "catalog:",
"mime": "catalog:",
"nodemailer": "catalog:",
@@ -88,7 +86,6 @@
},
"devDependencies": {
"@types/archiver": "catalog:",
"@types/lodash": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"typescript": "catalog:ts7",
-8
View File
@@ -5,12 +5,6 @@ const CACHE_INFLIGHT_JOIN_RETRIES = 1;
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
interface CacheMSetEntry<T> {
key: string;
value: T;
ttlSeconds?: number;
}
interface CacheProduceTracking {
generation: number;
produces: number;
@@ -54,8 +48,6 @@ export abstract class ICacheService {
abstract mget<T>(keys: Array<string>): Promise<Array<T | null>>;
abstract mset<T>(entries: Array<CacheMSetEntry<T>>): Promise<void>;
abstract deletePattern(pattern: string): Promise<number>;
abstract acquireLock(key: string, ttlSeconds: number): Promise<string | null>;
@@ -1,57 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
function createRecordingProvider(): {
client: IKVProvider;
commands: Array<Array<string>>;
} {
const commands: Array<Array<string>> = [];
const client = {
set: async (key: string) => {
commands.push([key]);
return 'OK';
},
setex: async (key: string) => {
commands.push([key]);
},
isClustered: () => true,
pipeline: () => {
const keys: Array<string> = [];
commands.push(keys);
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => [],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return {client, commands};
}
describe('KVCacheProvider cluster hash slots', () => {
it('keeps a multi entry write off batched commands that span hash slots', async () => {
const {client, commands} = createRecordingProvider();
const provider = new KVCacheProvider({client});
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
await provider.mset([
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
{key: 'cache:beta', value: 2},
]);
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
});
});
-107
View File
@@ -1,107 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
const MAX_CONCURRENT_ROUND_TRIPS = 16;
interface RecordingProvider {
client: IKVProvider;
batches: Array<Array<string>>;
peakInFlight: number;
}
function createRecordingProvider(clustered: boolean): RecordingProvider {
const recorder: RecordingProvider = {
client: {} as IKVProvider,
batches: [],
peakInFlight: 0,
};
let inFlight = 0;
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
recorder.batches.push(keys);
inFlight += 1;
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
};
recorder.client = {
isClustered: () => clustered,
set: async (key: string) => {
await trackRoundTrip([key]);
return 'OK';
},
setex: async (key: string) => {
await trackRoundTrip([key]);
},
pipeline: () => {
const keys: Array<string> = [];
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => {
await trackRoundTrip(keys);
return [];
},
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return recorder;
}
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
return Array.from({length: count}, (_unused, index) => ({
key: `cache:entry:${index}`,
value: index,
ttlSeconds: 60,
}));
}
describe('KVCacheProvider multi entry write fan out', () => {
it('writes every entry in one round trip outside cluster mode', async () => {
const recorder = createRecordingProvider(false);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
expect(recorder.peakInFlight).toBe(1);
});
it('surfaces a failed command inside a batched write', async () => {
const client = {
isClustered: () => false,
pipeline: () => {
const batch = {
set: () => batch,
setex: () => batch,
exec: async () => [[new Error('write rejected'), null]],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
const provider = new KVCacheProvider({client});
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
});
it('bounds concurrent round trips when entries span hash slots', async () => {
const recorder = createRecordingProvider(true);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
expect(recorder.batches.flat().length).toBe(1000);
});
});
+20 -39
View File
@@ -1,38 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
import {describe, expect, it, vi} from 'vitest';
function createKVCacheProvider(): {
provider: KVCacheProvider;
store: Map<string, string>;
ttls: Array<[string, number]>;
} {
const store = new Map<string, string>();
const ttls: Array<[string, number]> = [];
const client = {
get: async (key: string) => store.get(key) ?? null,
set: async (key: string, value: string) => {
store.set(key, value);
return 'OK';
},
setex: async (key: string, ttlSeconds: number, value: string) => {
ttls.push([key, ttlSeconds]);
store.set(key, value);
},
} as unknown as IKVProvider;
return {provider: new KVCacheProvider({client}), store, ttls};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('ICacheService.getOrSet', () => {
it('runs the factory once for concurrent callers on the same key', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const factory = vi.fn(async () => {
await delay(10);
return 7;
@@ -48,7 +26,7 @@ describe('ICacheService.getOrSet', () => {
});
it('does not coalesce concurrent callers on different keys', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const factory = vi.fn(async () => {
await delay(10);
return 1;
@@ -58,7 +36,7 @@ describe('ICacheService.getOrSet', () => {
});
it('caches a null factory result and serves it as a hit', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const factory = vi.fn(async () => null);
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
@@ -66,36 +44,39 @@ describe('ICacheService.getOrSet', () => {
});
it('serves a stored json null from the kv provider as a hit', async () => {
const {provider, store} = createKVCacheProvider();
const client = new MockKVProvider();
const provider = new KVCacheProvider({client});
const factory = vi.fn(async () => null);
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(store.get('key')).toBe('null');
expect(await client.get('key')).toBe('null');
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('treats an unparseable stored value as a miss', async () => {
const {provider, store} = createKVCacheProvider();
store.set('key', '{not json');
const client = new MockKVProvider();
const provider = new KVCacheProvider({client});
await client.set('key', '{not json');
const factory = vi.fn(async () => 3);
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
expect(factory).toHaveBeenCalledTimes(1);
});
it('resolves the ttl from the produced value', async () => {
const {provider, store, ttls} = createKVCacheProvider();
it('resolves the ttl from the produced value', async ({onTestFinished}) => {
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
onTestFinished(() => now.mockRestore());
const client = new MockKVProvider();
const provider = new KVCacheProvider({client});
const resolver = (value: number | null) => (value === null ? 5 : 30);
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
expect(ttls).toEqual([
['present', 30],
['absent', 5],
]);
expect(store.get('absent')).toBe('null');
expect(await client.ttl('present')).toBe(30);
expect(await client.ttl('absent')).toBe(5);
expect(await client.get('absent')).toBe('null');
});
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const failing = vi.fn(async () => {
await delay(10);
throw new Error('factory failed');
@@ -115,7 +96,7 @@ describe('ICacheService.getOrSet', () => {
});
it('does not fan a transient producer failure out to the callers that joined it', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
let calls = 0;
const factory = vi.fn(async () => {
calls += 1;
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
import {describe, expect, it, vi} from 'vitest';
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
@@ -31,13 +33,13 @@ function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned'
]);
}
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
function trackedProduceKeys(cache: ICacheService): Array<string> {
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
}
describe('cache invalidation during an in-flight produce', () => {
it('does not resurrect a value deleted while the factory was running', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await cache.delete('session');
@@ -47,7 +49,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('still stores the value when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
gate.resolve('live-session');
@@ -56,7 +58,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not resurrect a value deleted while a retried produce was running', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
@@ -77,7 +79,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('stores the value a retried produce built when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
@@ -96,7 +98,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('stores the value a retried produce built after the first produce was invalidated', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
@@ -117,7 +119,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await flush();
@@ -130,7 +132,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
@@ -157,7 +159,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('drops produce tracking once the last produce for a key settles', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
for (let index = 0; index < 50; index++) {
const gate = deferred<string>();
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
@@ -184,7 +186,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not pin a key forever when the factory never settles', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const stuck = deferred<string>();
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
@@ -197,7 +199,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not store a value produced by a factory that settled after the timeout', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
@@ -208,7 +210,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('retries once for the joiners when the producer times out', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
@@ -229,7 +231,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('releases produce tracking when the factory never settles', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
@@ -238,7 +240,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
@@ -262,7 +264,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('does not hold the event loop open while a produce is in flight', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const stuck = deferred<string>();
const timers: Array<NodeJS.Timeout> = [];
const scheduled = globalThis.setTimeout;
@@ -282,7 +284,7 @@ describe('cache invalidation during an in-flight produce', () => {
});
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
const cache = new InMemoryProvider();
const cache = new KVCacheProvider({client: new MockKVProvider()});
const first = deferred<string>();
const pending = cache.getOrSet('session', async () => await first.promise, 30);
await cache.delete('session');
-273
View File
@@ -1,273 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
formatLockKey,
generateLockToken,
validateLockKey,
validateLockToken,
} from '@pkgs/cache/src/CacheLockValidation';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
interface CacheEntry {
expiresAt?: number;
}
interface CacheValueEntry extends CacheEntry {
kind: 'value';
value: unknown;
}
interface CacheSetEntry extends CacheEntry {
kind: 'set';
value: Set<string>;
}
interface CacheLockEntry extends CacheEntry {
token: string;
expiresAt: number;
}
interface InMemoryProviderConfig {
maxSize?: number;
cleanupIntervalMs?: number;
}
export class InMemoryProvider extends ICacheService {
private readonly cache = new Map<string, CacheValueEntry | CacheSetEntry>();
private readonly locks = new Map<string, CacheLockEntry>();
private readonly maxSize: number;
private cleanupInterval?: NodeJS.Timeout;
constructor(config: InMemoryProviderConfig = {}) {
super();
this.maxSize = config.maxSize ?? 10000;
if (config.cleanupIntervalMs) {
this.cleanupInterval = setInterval(() => this.cleanup(), config.cleanupIntervalMs);
}
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry, now)) {
this.cache.delete(key);
}
}
for (const [key, lock] of this.locks.entries()) {
if (this.isExpired(lock, now)) {
this.locks.delete(key);
}
}
}
private isExpired(entry: CacheEntry, now = Date.now()): boolean {
return entry.expiresAt !== undefined && now >= entry.expiresAt;
}
private getLiveEntry(key: string): CacheValueEntry | CacheSetEntry | undefined {
const entry = this.cache.get(key);
if (entry && this.isExpired(entry)) {
this.cache.delete(key);
return undefined;
}
return entry;
}
private getValueEntry(key: string): CacheValueEntry | undefined {
const entry = this.getLiveEntry(key);
if (entry && entry.kind !== 'value') {
throw new Error('Cache key contains a set, not a value.');
}
return entry;
}
private getSetEntry(key: string): CacheSetEntry | undefined {
const entry = this.getLiveEntry(key);
if (entry && entry.kind !== 'set') {
throw new Error('Cache key contains a value, not a set.');
}
return entry;
}
private getLiveLock(key: string): CacheLockEntry | undefined {
const lock = this.locks.get(key);
if (lock && this.isExpired(lock)) {
this.locks.delete(key);
return undefined;
}
return lock;
}
private evictIfNeeded(): void {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
}
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
const entry = this.getValueEntry(key);
return entry ? {hit: true, value: entry.value as T} : {hit: false};
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
if (!this.cache.has(key)) {
this.evictIfNeeded();
}
const entry: CacheValueEntry = {
kind: 'value',
value,
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
};
this.cache.set(key, entry);
}
protected async deleteEntry(key: string): Promise<void> {
this.cache.delete(key);
}
async getAndDelete<T>(key: string): Promise<T | null> {
const entry = this.getValueEntry(key);
this.cache.delete(key);
return entry ? (entry.value as T) : null;
}
async exists(key: string): Promise<boolean> {
return this.getLiveEntry(key) !== undefined;
}
async expire(key: string, ttlSeconds: number): Promise<void> {
const entry = this.getLiveEntry(key);
if (entry) {
entry.expiresAt = Date.now() + ttlSeconds * 1000;
}
}
async ttl(key: string): Promise<number> {
const entry = this.getLiveEntry(key);
if (!entry) {
return -2;
}
if (entry.expiresAt === undefined) {
return -1;
}
const ttlMs = entry.expiresAt - Date.now();
return Math.max(0, Math.floor(ttlMs / 1000));
}
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
return keys.map((key) => {
const entry = this.getLiveEntry(key);
return entry?.kind === 'value' ? (entry.value as T) : null;
});
}
async mset<T>(
entries: Array<{
key: string;
value: T;
ttlSeconds?: number;
}>,
): Promise<void> {
for (const entry of entries) {
await this.set(entry.key, entry.value, entry.ttlSeconds);
}
}
async deletePattern(pattern: string): Promise<number> {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
let deletedCount = 0;
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
deletedCount++;
}
}
return deletedCount;
}
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
validateLockKey(key);
const lockKey = formatLockKey(key);
if (this.getLiveLock(lockKey)) {
return null;
}
const token = generateLockToken();
this.locks.set(lockKey, {
token,
expiresAt: Date.now() + ttlSeconds * 1000,
});
return token;
}
async releaseLock(key: string, token: string): Promise<boolean> {
validateLockKey(key);
validateLockToken(token);
const lockKey = formatLockKey(key);
const lock = this.getLiveLock(lockKey);
if (!lock || lock.token !== token) {
return false;
}
this.locks.delete(lockKey);
return true;
}
async extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean> {
validateLockKey(key);
validateLockToken(token);
const lockKey = formatLockKey(key);
const lock = this.getLiveLock(lockKey);
if (!lock || lock.token !== token) {
return false;
}
lock.expiresAt = Date.now() + ttlSeconds * 1000;
return true;
}
async publish(_channel: string, _message: string): Promise<void> {
return;
}
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
let entry = this.getSetEntry(key);
if (!entry) {
this.evictIfNeeded();
entry = {kind: 'set', value: new Set<string>()};
this.cache.set(key, entry);
}
entry.value.add(member);
if (ttlSeconds) {
entry.expiresAt = Date.now() + ttlSeconds * 1000;
}
}
async srem(key: string, member: string): Promise<void> {
const entry = this.getSetEntry(key);
if (!entry) {
return;
}
entry.value.delete(member);
if (entry.value.size === 0) {
this.cache.delete(key);
}
}
async smembers(key: string): Promise<Set<string>> {
return new Set(this.getSetEntry(key)?.value);
}
async sismember(key: string, member: string): Promise<boolean> {
return this.getSetEntry(key)?.value.has(member) ?? false;
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.cache.clear();
this.locks.clear();
}
}
-28
View File
@@ -11,7 +11,6 @@ import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTyp
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
interface KVCacheProviderConfig {
client: IKVProvider;
@@ -138,33 +137,6 @@ export class KVCacheProvider extends ICacheService {
});
}
async mset<T>(
entries: Array<{
key: string;
value: T;
ttlSeconds?: number;
}>,
): Promise<void> {
if (entries.length === 0) return;
const serialized = entries.map((entry) => ({
key: entry.key,
value: serializeValue(entry.value),
ttlSeconds: entry.ttlSeconds,
}));
const batches = splitIntoSlotBatches(serialized, (entry) => entry.key, this.client.isClustered());
await runSlotBatches(batches, async (batch) => {
const pipeline = this.client.pipeline();
for (const entry of batch) {
if (entry.ttlSeconds) {
pipeline.setex(entry.key, entry.ttlSeconds, entry.value);
} else {
pipeline.set(entry.key, entry.value);
}
}
await this.executePipeline(pipeline);
});
}
async deletePattern(pattern: string): Promise<number> {
const keys = await this.client.scan(pattern, 1000);
if (keys.length === 0) return 0;
@@ -237,6 +237,10 @@ export class ElasticsearchIndexAdapter<
this.assertBulkSucceeded(response, 'delete');
}
async deleteByTerm(field: string, value: string): Promise<void> {
await this.deleteByQuery({term: {[field]: value}});
}
async deleteByQuery(query: Record<string, unknown>): Promise<void> {
this.assertInitialised();
const response = await this.client.deleteByQuery({
+1
View File
@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"@fluxer/i18n": "workspace:*",
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:",
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {createTransport} = vi.hoisted(() => ({createTransport: vi.fn()}));
vi.mock('nodemailer', () => ({default: {createTransport}}));
describe('SmtpEmailProvider', () => {
beforeEach(() => {
createTransport.mockReset();
createTransport.mockReturnValue({sendMail: vi.fn(), verify: vi.fn()});
});
it.each([
{port: 465, tlsMode: undefined, secure: true, requireTLS: false, ignoreTLS: false},
{port: 587, tlsMode: undefined, secure: false, requireTLS: true, ignoreTLS: false},
{port: 587, tlsMode: null, secure: false, requireTLS: true, ignoreTLS: false},
{port: 587, tlsMode: 'implicit', secure: true, requireTLS: false, ignoreTLS: false},
{port: 465, tlsMode: 'starttls', secure: false, requireTLS: true, ignoreTLS: false},
{port: 25, tlsMode: 'opportunistic', secure: false, requireTLS: false, ignoreTLS: false},
{port: 587, tlsMode: 'none', secure: false, requireTLS: false, ignoreTLS: true},
] as const)('maps TLS mode $tlsMode on port $port to nodemailer options', ({port, tlsMode, ...expected}) => {
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
});
});
+12 -3
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveSmtpTlsMode, type SmtpTlsMode, SmtpTlsModes} from '@fluxer/constants/src/SmtpConstants';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import nodemailer, {type Transporter} from 'nodemailer';
@@ -11,7 +12,7 @@ interface SmtpEmailConfig {
port: number;
username: string;
password: string;
secure?: boolean;
tlsMode?: SmtpTlsMode | null;
connectionTimeoutMs?: number;
greetingTimeoutMs?: number;
socketTimeoutMs?: number;
@@ -19,12 +20,20 @@ interface SmtpEmailConfig {
export class SmtpEmailProvider implements IEmailProvider {
private readonly transporter: Transporter;
private readonly host: string;
private readonly port: number;
private readonly tlsMode: SmtpTlsMode;
constructor(config: SmtpEmailConfig) {
this.host = config.host;
this.port = config.port;
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure ?? true,
secure: this.tlsMode === SmtpTlsModes.IMPLICIT,
requireTLS: this.tlsMode === SmtpTlsModes.STARTTLS,
ignoreTLS: this.tlsMode === SmtpTlsModes.NONE,
auth: {
user: config.username,
pass: config.password,
@@ -52,7 +61,7 @@ export class SmtpEmailProvider implements IEmailProvider {
logger.debug({to: message.to}, 'Email sent via SMTP');
return true;
} catch (error) {
logger.error({error}, 'SMTP send failed');
logger.error({error, host: this.host, port: this.port, tlsMode: this.tlsMode}, 'SMTP send failed');
return false;
}
}
+20 -87
View File
@@ -10,28 +10,8 @@ export interface SentEmailRecord {
metadata: Record<string, string>;
}
function maskToken(token: string): string {
if (token.length <= 8) {
return '*'.repeat(token.length);
}
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
const noopLogger = {info: (_message: string) => {}};
export class TestEmailService implements IEmailService {
private readonly sentEmails: Array<SentEmailRecord> = [];
private readonly logger: {
info: (message: string) => void;
};
constructor(options?: {
logger?: {
info: (message: string) => void;
};
}) {
this.logger = options?.logger ?? noopLogger;
}
listSentEmails(): Array<SentEmailRecord> {
return [...this.sentEmails];
@@ -43,51 +23,41 @@ export class TestEmailService implements IEmailService {
async sendPasswordResetEmail(
email: string,
username: string,
_username: string,
resetToken: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Password reset email sent to ${email} for user ${username}, token: ${maskToken(resetToken)}`);
return this.record(email, 'password_reset', {token: resetToken});
}
async sendEmailVerification(
email: string,
username: string,
_username: string,
verificationToken: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Email verification sent to ${email} for user ${username}, token: ${maskToken(verificationToken)}`,
);
return this.record(email, 'email_verification', {token: verificationToken});
}
async sendIpAuthorizationEmail(
email: string,
username: string,
_username: string,
authorizationToken: string,
ipAddress: string,
location: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`IP authorization email sent to ${email} for user ${username}, IP: ${ipAddress}, location: ${location}`,
);
return this.record(email, 'ip_authorization', {token: authorizationToken, ip: ipAddress, location});
}
async sendAccountTempBannedEmail(
email: string,
username: string,
_username: string,
reason: string | null,
durationHours: number,
bannedUntil: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Temp ban email sent to ${email} for user ${username}, ${durationHours}h until ${bannedUntil.toISOString()}`,
);
return this.record(email, 'account_temp_banned', {
reason: reason ?? '',
duration_hours: durationHours.toString(10),
@@ -97,14 +67,11 @@ export class TestEmailService implements IEmailService {
async sendAccountScheduledForDeletionEmail(
email: string,
username: string,
_username: string,
reason: string | null,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Scheduled deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
);
return this.record(email, 'account_scheduled_deletion', {
reason: reason ?? '',
deletion_date: deletionDate.toISOString(),
@@ -113,24 +80,20 @@ export class TestEmailService implements IEmailService {
async sendSelfDeletionScheduledEmail(
email: string,
username: string,
_username: string,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Self deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`);
return this.record(email, 'self_deletion_scheduled', {deletion_date: deletionDate.toISOString()});
}
async sendAccountDeletionRequestedEmail(
email: string,
username: string,
_username: string,
reason: string | null,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Requested deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
);
return this.record(email, 'account_deletion_scheduled_requested', {
reason: reason ?? '',
deletion_date: deletionDate.toISOString(),
@@ -139,45 +102,37 @@ export class TestEmailService implements IEmailService {
async sendAccountDeletionInactivityEmail(
email: string,
username: string,
_username: string,
reason: string | null,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Inactivity deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
);
return this.record(email, 'account_deletion_scheduled_inactivity', {
reason: reason ?? '',
deletion_date: deletionDate.toISOString(),
});
}
async sendAccountDeletionCancelledEmail(email: string, username: string, _locale?: string | null): Promise<boolean> {
this.logger.info(`Deletion cancelled email sent to ${email} for user ${username}`);
async sendAccountDeletionCancelledEmail(email: string, _username: string, _locale?: string | null): Promise<boolean> {
return this.record(email, 'account_deletion_cancelled');
}
async sendUnbanNotification(
email: string,
username: string,
_username: string,
reason: string | null,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason ?? 'none'}`);
return this.record(email, 'unban_notification', {reason: reason ?? ''});
}
async sendScheduledDeletionNotification(
email: string,
username: string,
_username: string,
deletionDate: Date,
reason: string | null,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Scheduled deletion notification sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
);
return this.record(email, 'scheduled_deletion_notification', {
deletion_date: deletionDate.toISOString(),
reason: reason ?? '',
@@ -186,14 +141,11 @@ export class TestEmailService implements IEmailService {
async sendInactivityWarningEmail(
email: string,
username: string,
_username: string,
deletionDate: Date,
lastActiveDate: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Inactivity warning sent to ${email} for user ${username}, deletion: ${deletionDate.toISOString()}`,
);
return this.record(email, 'inactivity_warning', {
deletion_date: deletionDate.toISOString(),
last_active_date: lastActiveDate.toISOString(),
@@ -202,16 +154,13 @@ export class TestEmailService implements IEmailService {
async sendHarvestCompletedEmail(
email: string,
username: string,
_username: string,
downloadUrl: string,
totalMessages: number,
fileSize: number,
expiresAt: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Harvest completed email sent to ${email} for user ${username}, ${totalMessages} messages, ${fileSize} bytes`,
);
return this.record(email, 'harvest_completed', {
download_url: downloadUrl,
total_messages: totalMessages.toString(10),
@@ -220,19 +169,17 @@ export class TestEmailService implements IEmailService {
});
}
async sendGiftChargebackNotification(email: string, username: string, _locale?: string | null): Promise<boolean> {
this.logger.info(`Gift chargeback notification sent to ${email} for user ${username}`);
async sendGiftChargebackNotification(email: string, _username: string, _locale?: string | null): Promise<boolean> {
return this.record(email, 'gift_chargeback_notification');
}
async sendReportResolvedEmail(
email: string,
username: string,
_username: string,
reportId: string,
publicComment: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Report resolved email sent to ${email} for user ${username}, report: ${reportId}`);
return this.record(email, 'report_resolved', {report_id: reportId, public_comment: publicComment});
}
@@ -242,7 +189,6 @@ export class TestEmailService implements IEmailService {
publicComment: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`DSA report resolved email sent to ${email}, report: ${reportId}`);
return this.record(email, 'dsa_report_resolved', {report_id: reportId, public_comment: publicComment});
}
@@ -252,7 +198,6 @@ export class TestEmailService implements IEmailService {
targetKind: ReportReceivedTargetKind,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Report received email sent to ${email}, report: ${reportId}`);
return this.record(email, 'report_received', {report_id: reportId, target_kind: targetKind});
}
@@ -262,53 +207,47 @@ export class TestEmailService implements IEmailService {
expiresAt: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`DSA report verification code sent to ${email}, code: ${maskToken(code)}`);
return this.record(email, 'dsa_report_verification', {code, expires_at: expiresAt.toISOString()});
}
async sendPasswordChangeVerification(
email: string,
username: string,
_username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Password change verification sent to ${email} for user ${username}`);
return this.record(email, 'password_change_verification', {code});
}
async sendMfaBackupCodesVerification(
email: string,
username: string,
_username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`MFA backup codes verification sent to ${email} for user ${username}`);
return this.record(email, 'mfa_backup_codes_view', {code});
}
async sendEmailChangeOriginal(
email: string,
username: string,
_username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Email change original verification sent to ${email} for user ${username}`);
return this.record(email, 'email_change_original', {code});
}
async sendEmailChangeNew(email: string, username: string, code: string, _locale?: string | null): Promise<boolean> {
this.logger.info(`Email change new verification sent to ${email} for user ${username}`);
async sendEmailChangeNew(email: string, _username: string, code: string, _locale?: string | null): Promise<boolean> {
return this.record(email, 'email_change_new', {code});
}
async sendEmailChangeRevert(
email: string,
username: string,
_username: string,
newEmail: string,
token: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Email change revert notice sent to ${email} for user ${username}`);
return this.record(email, 'email_change_revert', {token, new_email: newEmail});
}
@@ -319,9 +258,6 @@ export class TestEmailService implements IEmailService {
expiresAt: Date,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Donation magic link sent to ${email}, token: ${maskToken(token)}, expires: ${expiresAt.toISOString()}`,
);
return this.record(email, 'donation_magic_link', {
token,
manage_url: manageUrl,
@@ -337,9 +273,6 @@ export class TestEmailService implements IEmailService {
manageUrl: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(
`Donation confirmation sent to ${email}, amount: ${amountCents / 100} ${currency.toUpperCase()}/${interval}`,
);
return this.record(email, 'donation_confirmation', {
amount: (amountCents / 100).toString(),
currency: currency.toUpperCase(),
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {identityLocale} from '@fluxer/i18n/src/normalization/IdentityLocale';
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
@@ -43,7 +42,6 @@ const emailI18n = createStaticI18n<EmailTemplateKey, EmailTemplate, Record<strin
defaultLocale: DEFAULT_LOCALE,
defaultMessages: EMAIL_I18N_MESSAGES,
localeMessages: EMAIL_I18N_LOCALE_MESSAGES,
normalizeLocale: (locale) => identityLocale(locale),
onWarning: (message) => {
if (message.startsWith('Unsupported locale, falling back to en-US:')) {
console.warn(
@@ -95,20 +95,6 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
};
describe('EmailI18n locale files', () => {
it.each(LOCALES)('%s loads without module errors', (locale) => {
const template = getEmailTemplate(
'email_verification',
locale,
{username: 'testuser', verifyUrl: 'https://example.com/verify'},
'Fluxer',
);
expect(template.ok).toBe(true);
});
it.each(LOCALES)('%s has the same translation keys as the source catalog', (locale) => {
const messagesKeys = Object.keys(EMAIL_I18N_MESSAGES).sort();
const localeKeys = Object.keys(EMAIL_I18N_LOCALE_MESSAGES[locale]).sort();
expect(localeKeys).toEqual(messagesKeys);
});
it.each(LOCALES)('%s keeps the source placeholders in every template', (locale) => {
const messages: Partial<Record<EmailTemplateKey, {subject: string; body: string}>> =
EMAIL_I18N_LOCALE_MESSAGES[locale];
Loaded 100 of 3079 files, more files were not shown because too many files have changed in this diff. Show more