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
2982 changed files with 43718 additions and 176953 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
+9 -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",
@@ -1835,7 +1824,6 @@ dependencies = [
"anyhow",
"base64 0.23.1",
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
@@ -1860,6 +1848,7 @@ dependencies = [
"base64 0.23.1",
"clap",
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"p256",
@@ -1900,6 +1889,7 @@ dependencies = [
"bytes",
"chrono",
"deadpool-postgres",
"fluxer_common",
"futures",
"libc",
"rmp-serde",
@@ -2019,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",
]
@@ -3314,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"
@@ -3711,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"
-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
+3 -4
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": [
{
@@ -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",
@@ -17271,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",
@@ -17567,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()
}
+8 -130
View File
@@ -136,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::*;
+22 -8
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::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
@@ -30,32 +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_saved_instance_smtp_config(
&self,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
self.post_typed("/admin/instance/config/smtp-tests", &serde_json::json!({}))
.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
@@ -81,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>,
+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 => {},
}
}
+4 -34
View File
@@ -57,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
}
+7 -43
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},
};
@@ -202,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)
}
@@ -216,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),
)
}
@@ -359,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);
+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) {
-371
View File
@@ -1380,27 +1380,6 @@ mod tests {
assert_eq!(body(from_an_older_page), serde_json::json!({}));
}
#[test]
fn smtp_test_flash_says_the_settings_were_saved_either_way() {
let passed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: true,
error: None,
});
assert_eq!(
passed.message,
"Settings saved and SMTP connection verified"
);
let failed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: false,
error: Some("wrong version number".to_owned()),
});
assert_eq!(
failed.message,
"Settings saved, but the SMTP test failed: wrong version number"
);
}
#[test]
fn build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
@@ -1435,65 +1414,6 @@ 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");
assert_eq!(
filters.traits,
vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()]
);
assert_eq!(
filters.guild_features,
vec!["COMMUNITY".to_owned(), "NEWS".to_owned()]
);
}
#[test]
fn instance_config_result_surfaces_the_api_validation_message() {
let error = crate::api::client::ApiError::Http {
status: 400,
message: serde_json::json!({
"code": "INVALID_FORM_BODY",
"message": "Input Validation Error",
"errors": [{
"path": "policy.premium_mode",
"code": "X",
"message": "Disable billing before switching the premium mode to everyone"
}]
})
.to_string(),
};
assert_eq!(
instance_config_result::<()>(Err(error)).message,
"Failed to update instance config: policy.premium_mode: Disable billing before switching the premium mode to everyone"
);
}
#[test]
fn limit_config_result_surfaces_the_api_validation_message() {
let error = crate::api::client::ApiError::Http {
status: 400,
message: serde_json::json!({
"code": "INVALID_FORM_BODY",
"message": "Input Validation Error",
"errors": [{
"path": "limit_config.rules.0.limits.max_bio_length",
"code": "VALUE_MUST_BE_INTEGER_IN_RANGE",
"message": "Value must be an integer between 0 and 320."
}]
})
.to_string(),
};
assert_eq!(
limit_config_result::<()>(Err(error), "Saved", "Failed to update limit configuration")
.message,
"Failed to update limit configuration: limit_config.rules.0.limits.max_bio_length: Value must be an integer between 0 and 320."
);
}
#[test]
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
let toggle = |key: &str, is_toggle: bool| {
@@ -1554,117 +1474,6 @@ mod tests {
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
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"
);
}
#[test]
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
@@ -1725,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"));
@@ -1814,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(
@@ -1905,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");
@@ -1947,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");
+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(
-31
View File
@@ -470,34 +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,
..PremiumBranding::default()
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
..PremiumBranding::default()
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
..PremiumBranding::default()
};
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::*;
-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))
}
}
}
@@ -242,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))
}
},
}
}
-50
View File
@@ -149,53 +149,3 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProxyConfig;
fn config() -> AdminConfig {
AdminConfig {
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: "https://static.example".to_owned(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
#[test]
fn instance_branding_replaces_the_fluxer_name_and_favicons() {
let branding = PremiumBranding {
product_name: Some("Example".to_owned()),
favicon_url: Some("https://media.example/favicon.png".to_owned()),
..PremiumBranding::default()
};
let links = favicon_links(&config(), Some(&branding)).into_string();
assert!(links.contains("https://media.example/favicon.png"));
assert!(!links.contains("static.example"));
assert_eq!(product_name(Some(&branding)), "Example");
}
#[test]
fn missing_branding_keeps_the_fluxer_name_and_favicons() {
let links = favicon_links(&config(), Some(&PremiumBranding::default())).into_string();
assert!(links.contains("https://static.example/web/favicon.ico"));
assert_eq!(product_name(None), "Fluxer");
}
}
+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 {
@@ -28,6 +28,7 @@ const CATEGORY_ORDER: &[&str] = &[
"expressions",
"files",
"social",
"media",
];
pub fn limit_config_page(
@@ -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>) {
@@ -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""#));
}
}
@@ -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,
}
}
-549
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#"{
@@ -714,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",
@@ -1473,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 -800
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,238 +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 limit_rule_reset_form_sends_the_rule_defaults() {
let updates = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = updates.clone();
let edited = || {
let mut config = limit_config();
config["limit_config"]["rules"][0]["limits"]["maxGuilds"] = json!(150);
config["limit_config"]["rules"][0]["modifiedFields"] = json!(["maxGuilds"]);
config
};
let api = Router::new()
.route(
"/admin/limit-config",
routing::get(move || async move { json_response(edited()) }).put(
move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body);
json_response(limit_config())
}
},
),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/limit-config", &[]).await;
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("limit config page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(
&page,
"/limit-config?action=reset&amp;rule=default",
&csrf_token,
);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let (status, _, body) = post_form_with_headers(
&app,
"/limit-config?action=reset&rule=default",
&[("Cookie", &cookie)],
&format!("_csrf={csrf_token}"),
)
.await;
assert!(
status.is_redirection() || status.is_success(),
"{status} {body}"
);
assert_eq!(
*updates.lock().unwrap(),
vec![json!({"limit_config": {
"traitDefinitions": [],
"rules": [{"id": "default", "limits": {"maxGuilds": 100}}]
}})]
);
}
#[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()));
@@ -612,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()
}
@@ -754,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,
@@ -802,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",
@@ -922,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": [
@@ -960,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({
+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];
-6
View File
@@ -17,11 +17,5 @@ export default defineConfig({
isolate: false,
reporters: ['default', 'json'],
outputFile: './test-results.json',
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json', 'html'],
reportsDirectory: './coverage',
exclude: ['**/node_modules/tests/test*.test.tsx', '**/*.test.ts'],
},
},
});
+3 -1
View File
@@ -13,7 +13,9 @@
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"undici": "catalog:"
"undici": "catalog:",
"@fluxer/schema": "workspace:*",
"@fluxer/ip_utils": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
import dns from 'node:dns';
import type {LookupFunction} from 'node:net';
import {BlockList, isIP} from 'node:net';
import {ipToBytes} from '@fluxer/ip_utils/src/IpAddress';
import {isFqdnHostname} from '@fluxer/schema/src/primitives/UrlValidators';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import type {
FetchDispatcher,
@@ -16,8 +18,6 @@ import {Agent, Dispatcher1Wrapper} from 'undici';
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
const DNS_CACHE_MAX_ENTRIES = 10000;
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const HOSTNAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
interface BlockedSubnet {
address: string;
prefixLength: number;
@@ -84,72 +84,9 @@ function normalizeHostname(hostname: string): string {
return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
}
function isFqdnHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253 || !hostname.includes('.')) {
return false;
}
const labels = hostname.split('.');
for (const label of labels) {
if (!label || label.length > 63 || !HOSTNAME_LABEL_REGEX.test(label)) {
return false;
}
}
const topLevelDomain = labels[labels.length - 1];
return !/^\d+$/.test(topLevelDomain);
}
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (isIP(normalized) !== 6) {
return null;
}
let head = normalized;
let embeddedIpv4Octets: Array<number> | null = null;
const lastColonIndex = head.lastIndexOf(':');
const trailing = head.slice(lastColonIndex + 1);
if (trailing.includes('.')) {
if (isIP(trailing) !== 4) {
return null;
}
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
}
let groups: Array<string>;
if (head.indexOf('::') === -1) {
groups = head.split(':');
if (groups.length !== 8) {
return null;
}
} else {
const [beforePart, afterPart] = head.split('::');
const before = beforePart.length > 0 ? beforePart.split(':') : [];
const after = afterPart.length > 0 ? afterPart.split(':') : [];
const missing = 8 - before.length - after.length;
if (missing < 1) {
return null;
}
groups = [...before, ...new Array(missing).fill('0'), ...after];
}
const bytes = new Uint8Array(16);
for (let index = 0; index < 8; index += 1) {
const value = parseHexGroup(groups[index]);
if (value === null) {
return null;
}
bytes[index * 2] = (value >> 8) & 0xff;
bytes[index * 2 + 1] = value & 0xff;
}
if (embeddedIpv4Octets) {
bytes[12] = embeddedIpv4Octets[0];
bytes[13] = embeddedIpv4Octets[1];
bytes[14] = embeddedIpv4Octets[2];
bytes[15] = embeddedIpv4Octets[3];
}
return bytes;
}
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
const bytes = expandIpv6ToBytes(ipv6Address);
const parsed = ipToBytes(ipv6Address);
const bytes = parsed?.family === 'ipv6' ? parsed.bytes : null;
if (!bytes) {
return null;
}
@@ -171,13 +108,6 @@ function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
return null;
}
function parseHexGroup(value: string | undefined): number | null {
if (!value || !/^[0-9a-f]{1,4}$/.test(value)) {
return null;
}
return Number.parseInt(value, 16);
}
function isBlockedIpAddress(address: string): boolean {
const normalizedAddress = stripIpv6Brackets(address.trim().toLowerCase());
const family = isIP(normalizedAddress);
@@ -50,30 +50,6 @@ describe('HttpClient', () => {
expect(leakedCookie).toBeUndefined();
expect(leakedProxyAuthorization).toBeUndefined();
});
it('keeps sensitive headers on same-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer safe-token';
let receivedAuthorization: string | undefined;
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount += 1;
if (requestCount === 1) {
res.writeHead(302, {Location: '/same-origin'});
res.end();
return;
}
receivedAuthorization = req.headers['authorization'] as string | undefined;
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
headers: {
Authorization: secretToken,
},
});
expect(receivedAuthorization).toBe(secretToken);
});
it('throws error when exceeding max redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
@@ -1,17 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface IKVPipeline {
get(key: string): this;
set(key: string, value: string): this;
setex(key: string, ttlSeconds: number, value: string): this;
del(key: string): this;
expire(key: string, ttlSeconds: number): this;
sadd(key: string, ...members: Array<string>): this;
srem(key: string, ...members: Array<string>): this;
zadd(key: string, score: number, value: string): this;
zrem(key: string, ...members: Array<string>): this;
hgetall(key: string): this;
mset(...args: Array<string>): this;
exec(): Promise<Array<[Error | null, unknown]>>;
}
@@ -22,7 +16,6 @@ export interface IKVSubscription {
off(event: 'message', callback: (channel: string, message: string) => void): void;
off(event: 'error', callback: (error: Error) => void): void;
subscribe(...channels: Array<string>): Promise<void>;
unsubscribe(...channels: Array<string>): Promise<void>;
quit(): Promise<void>;
disconnect(): Promise<void>;
}
@@ -47,20 +40,16 @@ export interface IKVProvider {
setex(key: string, ttlSeconds: number, value: string): Promise<void>;
setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean>;
mget(...keys: Array<string>): Promise<Array<string | null>>;
mset(...args: Array<string>): Promise<void>;
del(...keys: Array<string>): Promise<number>;
exists(key: string): Promise<number>;
expire(key: string, ttlSeconds: number): Promise<number>;
ttl(key: string): Promise<number>;
incr(key: string): Promise<number>;
getex(key: string, ttlSeconds: number): Promise<string | null>;
getdel(key: string): Promise<string | null>;
sadd(key: string, ...members: Array<string>): Promise<number>;
srem(key: string, ...members: Array<string>): Promise<number>;
smembers(key: string): Promise<Array<string>>;
sismember(key: string, member: string): Promise<number>;
scard(key: string): Promise<number>;
spop(key: string, count?: number): Promise<Array<string>>;
zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number>;
zrem(key: string, ...members: Array<string>): Promise<number>;
zcard(key: string): Promise<number>;
-36
View File
@@ -17,7 +17,6 @@ import {
import {createInvalidResponseError, KVClientError, KVClientErrorCode} from '@pkgs/kv_client/src/KVClientError';
import {resolveKVClusterConnection} from '@pkgs/kv_client/src/KVClusterConnection';
import {
createStringEntriesFromPairs,
createZSetMembersFromScorePairs,
normalizeScoreBound,
parseRangeByScoreArguments,
@@ -341,20 +340,6 @@ export class KVClient implements IKVProvider {
});
}
async mset(...args: Array<string>): Promise<void> {
const entries = createStringEntriesFromPairs(args);
if (entries.length === 0) {
return;
}
await this.execute('mset', async () => {
const batches = this.splitBySlot(entries, (entry) => entry.key);
await runSlotBatches(batches, async (batch) => {
const result = await this.client.mset(...batch.flatMap((entry) => [entry.key, entry.value]));
parseSetReply(result, 'mset');
});
});
}
async del(...keys: Array<string>): Promise<number> {
if (keys.length === 0) {
return 0;
@@ -381,17 +366,6 @@ export class KVClient implements IKVProvider {
return await this.execute('ttl', async () => this.client.ttl(key));
}
async incr(key: string): Promise<number> {
return await this.execute('incr', async () => this.client.incr(key));
}
async getex(key: string, ttlSeconds: number): Promise<string | null> {
return await this.execute('getex', async () => {
const result = await this.client.call('GETEX', key, 'EX', ttlSeconds);
return parseNullableStringReply(result, 'getex');
});
}
async getdel(key: string): Promise<string | null> {
return await this.execute('getdel', async () => {
const result = await this.client.call('GETDEL', key);
@@ -425,16 +399,6 @@ export class KVClient implements IKVProvider {
return await this.execute('scard', async () => this.client.scard(key));
}
async spop(key: string, count: number = 1): Promise<Array<string>> {
if (count <= 0) {
return [];
}
return await this.execute('spop', async () => {
const result = await this.client.spop(key, count);
return parsePoppedValues(result, 'spop', count);
});
}
async zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number> {
if (scoreMembers.length === 0) {
return 0;
@@ -45,26 +45,6 @@ export function parseSetArguments(args: Array<string | number>): KVSetOptions {
};
}
export function createStringEntriesFromPairs(args: Array<string>): Array<{
key: string;
value: string;
}> {
if (args.length % 2 !== 0) {
throw createInvalidArgumentError('mset requires key/value pairs');
}
const entries: Array<{
key: string;
value: string;
}> = [];
for (let index = 0; index < args.length; index += 2) {
entries.push({
key: args[index],
value: args[index + 1],
});
}
return entries;
}
export function createZSetMembersFromScorePairs(scoreMembers: Array<number | string>): Array<{
score: number;
value: string;
@@ -2,7 +2,6 @@
import type {IKVPipeline} from '@pkgs/kv_client/src/IKVProvider';
import {createInvalidResponseError} from '@pkgs/kv_client/src/KVClientError';
import {createStringEntriesFromPairs} from '@pkgs/kv_client/src/KVCommandArguments';
import type {ChainableCommander} from 'ioredis';
type PipelineExecResult = [Error | null, unknown];
@@ -27,16 +26,6 @@ export class KVPipeline implements IKVPipeline {
this.commander = options.createCommander();
}
get(key: string): this {
this.commander.get(key);
return this;
}
set(key: string, value: string): this {
this.commander.set(key, value);
return this;
}
setex(key: string, ttlSeconds: number, value: string): this {
this.commander.setex(key, ttlSeconds, value);
return this;
@@ -57,36 +46,11 @@ export class KVPipeline implements IKVPipeline {
return this;
}
srem(key: string, ...members: Array<string>): this {
this.commander.srem(key, ...members);
return this;
}
zadd(key: string, score: number, value: string): this {
this.commander.zadd(key, score, value);
return this;
}
zrem(key: string, ...members: Array<string>): this {
this.commander.zrem(key, ...members);
return this;
}
hgetall(key: string): this {
this.commander.hgetall(key);
return this;
}
mset(...args: Array<string>): this {
const entries = createStringEntriesFromPairs(args);
if (entries.length === 0) {
return this;
}
const pairs = entries.flatMap((entry) => [entry.key, entry.value]);
this.commander.mset(...pairs);
return this;
}
async exec(): Promise<Array<PipelineExecResult>> {
const command = `${this.mode}.exec`;
try {
@@ -200,20 +200,6 @@ export class KVSubscription implements IKVSubscription {
this.assertCurrentClient(client);
}
async unsubscribe(...channels: Array<string>): Promise<void> {
this.assertNotClosing();
const requestedChannels = [...new Set(channels)];
for (const channel of requestedChannels) {
this.desiredChannels.delete(channel);
}
const client = this.client;
if (requestedChannels.length === 0 || client === null) {
return;
}
await client.unsubscribe(...requestedChannels);
this.assertCurrentClient(client);
}
async quit(): Promise<void> {
if (this.closing !== null) {
return await this.closing;
@@ -177,23 +177,6 @@ describe('KVClient script execution', () => {
expect(evalMock).not.toHaveBeenCalled();
});
it('normalizes timeouts raised by the EVALSHA attempt', async () => {
evalshaMock.mockRejectedValue(new Error('Command timed out'));
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
code: KVClientErrorCode.TIMEOUT,
message: 'KV request timed out: releaseLock',
});
});
it('normalizes failures raised by the EVAL fallback', async () => {
evalshaMock.mockRejectedValue(noScriptError());
evalMock.mockRejectedValue(new Error('Connection is closed.'));
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
code: KVClientErrorCode.REQUEST_FAILED,
message: 'KV request failed (releaseLock): Connection is closed.',
});
});
it('reports invalid JSON from a scripted command', async () => {
evalshaMock.mockResolvedValue('not json');
await expect(createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1)).rejects.toMatchObject({
@@ -1,90 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVClient} from '@pkgs/kv_client/src/KVClient';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {commands, store} = vi.hoisted(() => ({
commands: [] as Array<{name: string; keys: Array<string>}>,
store: new Map<string, string>(),
}));
vi.mock('ioredis', () => {
class MockRedis {
async get(key: string): Promise<string | null> {
commands.push({name: 'get', keys: [key]});
return store.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
commands.push({name: 'set', keys: [key]});
store.set(key, value);
return 'OK';
}
async del(...keys: Array<string>): Promise<number> {
commands.push({name: 'del', keys});
return keys.filter((key) => store.delete(key)).length;
}
async mget(...keys: Array<string>): Promise<Array<string | null>> {
commands.push({name: 'mget', keys});
return keys.map((key) => store.get(key) ?? null);
}
async mset(...args: Array<string>): Promise<string> {
const keys: Array<string> = [];
for (let index = 0; index + 1 < args.length; index += 2) {
keys.push(args[index]);
store.set(args[index], args[index + 1]);
}
commands.push({name: 'mset', keys});
return 'OK';
}
}
return {default: MockRedis, Cluster: MockRedis};
});
function crossSlotCommands(): Array<{name: string; keys: Array<string>}> {
return commands.filter((command) => new Set(command.keys.map(computeHashSlot)).size > 1);
}
function createClusteredClient(): KVClient {
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
}
function createStandaloneClient(): KVClient {
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'standalone'});
}
describe('KVClient cluster hash slots', () => {
beforeEach(() => {
commands.length = 0;
store.clear();
});
it('deletes several keys without a command spanning hash slots', async () => {
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
const client = createClusteredClient();
await client.set('slot:alpha', 'one');
await client.set('slot:beta', 'two');
await expect(client.del('slot:alpha', 'slot:beta', 'slot:gamma')).resolves.toBe(2);
expect(store.size).toBe(0);
expect(crossSlotCommands()).toEqual([]);
});
it('keeps multi key commands whole outside cluster mode', async () => {
const client = createStandaloneClient();
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', 'two']);
await expect(client.del('slot:alpha', 'slot:beta')).resolves.toBe(2);
expect(commands).toEqual([
{name: 'mset', keys: ['slot:alpha', 'slot:beta']},
{name: 'mget', keys: ['slot:alpha', 'slot:beta']},
{name: 'del', keys: ['slot:alpha', 'slot:beta']},
]);
});
});
@@ -26,16 +26,6 @@ vi.mock('ioredis', () => {
return keys.map((key) => store.get(key) ?? null);
}
async mset(...args: Array<string>): Promise<string> {
const keys: Array<string> = [];
for (let index = 0; index + 1 < args.length; index += 2) {
keys.push(args[index]);
store.set(args[index], args[index + 1]);
}
await trackRoundTrip('mset', keys);
return 'OK';
}
async del(...keys: Array<string>): Promise<number> {
await trackRoundTrip('del', keys);
return keys.length;
@@ -81,10 +71,10 @@ describe('KVClient multi key fan out', () => {
expect(crossSlotCommands()).toEqual([]);
});
it('bounds concurrent round trips for a cluster write', async () => {
it('bounds concurrent round trips for a cluster delete', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
await client.mset(...createKeys(1000).flatMap((key) => [key, 'value']));
await expect(client.del(...createKeys(1000))).resolves.toBe(1000);
expect(tracker.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(crossSlotCommands()).toEqual([]);
@@ -103,7 +93,8 @@ describe('KVClient multi key fan out', () => {
it('keeps values ordered when a cluster read is split by slot', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
await client.mset('fanout:a', 'one', 'fanout:b', 'two');
store.set('fanout:a', 'one');
store.set('fanout:b', 'two');
const values = await client.mget('fanout:a', 'fanout:missing', 'fanout:b');
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import type {IKVPipeline} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
type BatchMode = 'multi' | 'pipeline';
@@ -30,16 +30,6 @@ export class BatchRecordingKVProvider extends MockKVProvider {
const batch: RecordedBatch = {mode, keys: []};
this.batches.push(batch);
const recorded: IKVPipeline = {
get: (key) => {
batch.keys.push(key);
inner.get(key);
return recorded;
},
set: (key, value) => {
batch.keys.push(key);
inner.set(key, value);
return recorded;
},
setex: (key, ttlSeconds, value) => {
batch.keys.push(key);
inner.setex(key, ttlSeconds, value);
@@ -60,33 +50,11 @@ export class BatchRecordingKVProvider extends MockKVProvider {
inner.sadd(key, ...members);
return recorded;
},
srem: (key, ...members) => {
batch.keys.push(key);
inner.srem(key, ...members);
return recorded;
},
zadd: (key, score, value) => {
batch.keys.push(key);
inner.zadd(key, score, value);
return recorded;
},
zrem: (key, ...members) => {
batch.keys.push(key);
inner.zrem(key, ...members);
return recorded;
},
hgetall: (key) => {
batch.keys.push(key);
inner.hgetall(key);
return recorded;
},
mset: (...args) => {
for (let index = 0; index + 1 < args.length; index += 2) {
batch.keys.push(args[index]);
}
inner.mset(...args);
return recorded;
},
exec: async () => await inner.exec(),
};
return recorded;
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IKVPipeline} from '@pkgs/kv_client/src/IKVProvider';
import {BatchRecordingKVProvider} from '@pkgs/kv_client/src/testing/BatchRecordingKVProvider';
export class FanoutRecordingKVProvider extends BatchRecordingKVProvider {
inFlight = 0;
peakInFlight = 0;
failingKey: string | null = null;
private insidePipeline = 0;
override pipeline(): IKVPipeline {
const inner = super.pipeline();
return {
...inner,
exec: async () =>
await this.trackRoundTrip(async () => {
this.insidePipeline += 1;
try {
return await inner.exec();
} finally {
this.insidePipeline -= 1;
}
}),
};
}
override async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
if (key === this.failingKey) {
throw new Error('write rejected');
}
if (this.insidePipeline > 0) {
await super.setex(key, ttlSeconds, value);
return;
}
this.batches.push({mode: 'pipeline', keys: [key]});
await this.trackRoundTrip(async () => {
await super.setex(key, ttlSeconds, value);
});
}
private async trackRoundTrip<T>(run: () => Promise<T>): Promise<T> {
this.inFlight += 1;
this.peakInFlight = Math.max(this.peakInFlight, this.inFlight);
try {
await new Promise((resolve) => setTimeout(resolve, 0));
return await run();
} finally {
this.inFlight -= 1;
}
}
}
@@ -1,104 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IKVPipeline, IKVProvider, IKVSubscription, KVRateLimitResult} from '@pkgs/kv_client/src/IKVProvider';
import {parseRangeByScoreArguments, parseSetArguments} from '@pkgs/kv_client/src/KVCommandArguments';
import {vi} from 'vitest';
type MessageCallback = (channel: string, message: string) => void;
type ErrorCallback = (error: Error) => void;
interface MockKVSubscriptionOptions {
shouldFailConnect?: boolean;
shouldFailSubscribe?: boolean;
}
class MockKVSubscription implements IKVSubscription {
private messageCallbacks: Array<MessageCallback> = [];
private errorCallbacks: Array<ErrorCallback> = [];
private options: MockKVSubscriptionOptions;
connectCalled = false;
private readonly messageCallbacks = new Set<MessageCallback>();
private readonly errorCallbacks = new Set<ErrorCallback>();
subscribedChannels: Array<string> = [];
quitCalled = false;
constructor(options: MockKVSubscriptionOptions = {}) {
this.options = options;
}
async connect(): Promise<void> {
if (this.options.shouldFailConnect) {
throw new Error('Mock connection failure');
}
this.connectCalled = true;
}
on(event: 'message', callback: MessageCallback): void;
on(event: 'error', callback: ErrorCallback): void;
async connect(): Promise<void> {}
on(event: 'message' | 'error', callback: MessageCallback | ErrorCallback): void {
if (event === 'message') {
this.messageCallbacks.push(callback as MessageCallback);
} else if (event === 'error') {
this.errorCallbacks.push(callback as ErrorCallback);
}
if (event === 'message') this.messageCallbacks.add(callback as MessageCallback);
else this.errorCallbacks.add(callback as ErrorCallback);
}
off(event: 'message', callback: MessageCallback): void;
off(event: 'error', callback: ErrorCallback): void;
off(event: 'message' | 'error', callback: MessageCallback | ErrorCallback): void {
if (event === 'message') {
this.messageCallbacks = this.messageCallbacks.filter((cb) => cb !== callback);
} else if (event === 'error') {
this.errorCallbacks = this.errorCallbacks.filter((cb) => cb !== callback);
}
if (event === 'message') this.messageCallbacks.delete(callback as MessageCallback);
else this.errorCallbacks.delete(callback as ErrorCallback);
}
deliver(channel: string, message: string): void {
if (this.subscribedChannels.includes(channel))
for (const callback of this.messageCallbacks) callback(channel, message);
}
async subscribe(...channels: Array<string>): Promise<void> {
if (this.options.shouldFailSubscribe) {
throw new Error('Mock subscribe failure');
}
this.subscribedChannels.push(...channels);
}
async unsubscribe(..._channels: Array<string>): Promise<void> {}
async quit(): Promise<void> {
this.quitCalled = true;
}
async disconnect(): Promise<void> {
this.quitCalled = true;
}
simulateMessage(channel: string, message: string): void {
for (const callback of this.messageCallbacks) {
callback(channel, message);
}
}
simulateError(error: Error): void {
for (const callback of this.errorCallbacks) {
callback(error);
}
}
async quit(): Promise<void> {}
async disconnect(): Promise<void> {}
reset(): void {
this.messageCallbacks = [];
this.errorCallbacks = [];
this.connectCalled = false;
this.subscribedChannels = [];
this.quitCalled = false;
this.messageCallbacks.clear();
this.errorCallbacks.clear();
}
}
interface MockKVProviderOptions {
subscriptionOptions?: MockKVSubscriptionOptions;
}
export class MockKVProvider implements IKVProvider {
rpushCalls: Array<{
key: string;
values: Array<string>;
}> = [];
clustered = true;
private subscription: MockKVSubscription;
private subscription = new MockKVSubscription();
private readonly stringStore = new Map<string, string>();
private readonly setStore = new Map<string, Set<string>>();
private readonly zsetStore = new Map<string, Map<string, number>>();
@@ -106,48 +46,10 @@ export class MockKVProvider implements IKVProvider {
private readonly hashStore = new Map<string, Map<string, string>>();
private readonly expiries = new Map<string, number>();
readonly getSpy = vi.fn();
readonly setSpy = vi.fn();
readonly setexSpy = vi.fn();
readonly setnxSpy = vi.fn();
readonly mgetSpy = vi.fn();
readonly msetSpy = vi.fn();
readonly delSpy = vi.fn();
readonly existsSpy = vi.fn();
readonly expireSpy = vi.fn();
readonly ttlSpy = vi.fn();
readonly incrSpy = vi.fn();
readonly getexSpy = vi.fn();
readonly getdelSpy = vi.fn();
readonly saddSpy = vi.fn();
readonly sremSpy = vi.fn();
readonly smembersSpy = vi.fn();
readonly sismemberSpy = vi.fn();
readonly scardSpy = vi.fn();
readonly spopSpy = vi.fn();
readonly zaddSpy = vi.fn();
readonly zremSpy = vi.fn();
readonly zcardSpy = vi.fn();
readonly zrangebyscoreSpy = vi.fn();
readonly rpushSpy = vi.fn();
readonly lpopSpy = vi.fn();
readonly llenSpy = vi.fn();
readonly hsetSpy = vi.fn();
readonly hdelSpy = vi.fn();
readonly hgetallSpy = vi.fn();
readonly publishSpy = vi.fn();
readonly acquireLockSpy = vi.fn();
readonly releaseLockSpy = vi.fn();
readonly checkLeakyBucketLimitSpy = vi.fn();
readonly scheduleBulkDeletionSpy = vi.fn();
readonly claimBulkDeletionSpy = vi.fn();
readonly removeBulkDeletionSpy = vi.fn();
readonly scanSpy = vi.fn();
readonly dequeuePurgeBatchSpy = vi.fn();
readonly evalScriptSpy = vi.fn();
constructor(options: MockKVProviderOptions = {}) {
this.subscription = new MockKVSubscription(options.subscriptionOptions);
}
async get(key: string): Promise<string | null> {
this.getSpy(key);
@@ -156,19 +58,18 @@ export class MockKVProvider implements IKVProvider {
}
async set(key: string, value: string, ...args: Array<string | number>): Promise<string | null> {
this.setSpy(key, value, ...args);
const useNx = args.includes('NX');
const {ttlSeconds, useNx} = parseSetArguments(args);
if (useNx && this.keyExists(key)) {
return null;
}
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.setExpiryFromArgs(key, args);
if (ttlSeconds !== undefined) this.expiries.set(key, Date.now() + ttlSeconds * 1000);
else this.expiries.delete(key);
return 'OK';
}
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
this.setexSpy(key, ttlSeconds, value);
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
@@ -197,20 +98,6 @@ export class MockKVProvider implements IKVProvider {
});
}
async mset(...args: Array<string>): Promise<void> {
this.msetSpy(...args);
for (let i = 0; i < args.length; i += 2) {
const key = args[i];
const value = args[i + 1];
if (value === undefined) {
continue;
}
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.expiries.delete(key);
}
}
async del(...keys: Array<string>): Promise<number> {
this.delSpy(...keys);
let deleted = 0;
@@ -223,12 +110,10 @@ export class MockKVProvider implements IKVProvider {
}
async exists(key: string): Promise<number> {
this.existsSpy(key);
return this.keyExists(key) ? 1 : 0;
}
async expire(key: string, ttlSeconds: number): Promise<number> {
this.expireSpy(key, ttlSeconds);
if (!this.keyExists(key)) {
return 0;
}
@@ -237,7 +122,6 @@ export class MockKVProvider implements IKVProvider {
}
async ttl(key: string): Promise<number> {
this.ttlSpy(key);
this.evictIfExpired(key);
if (!this.keyExists(key)) {
return -2;
@@ -254,27 +138,7 @@ export class MockKVProvider implements IKVProvider {
return remainingSeconds;
}
async incr(key: string): Promise<number> {
this.incrSpy(key);
this.evictIfExpired(key);
const value = Number.parseInt(this.stringStore.get(key) ?? '0', 10) + 1;
this.ensureType(key, 'string');
this.stringStore.set(key, String(value));
return value;
}
async getex(key: string, ttlSeconds: number): Promise<string | null> {
this.getexSpy(key, ttlSeconds);
this.evictIfExpired(key);
const value = this.stringStore.get(key) ?? null;
if (value !== null) {
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
}
return value;
}
async getdel(key: string): Promise<string | null> {
this.getdelSpy(key);
this.evictIfExpired(key);
const value = this.stringStore.get(key) ?? null;
this.deleteKey(key);
@@ -282,7 +146,6 @@ export class MockKVProvider implements IKVProvider {
}
async sadd(key: string, ...members: Array<string>): Promise<number> {
this.saddSpy(key, ...members);
this.evictIfExpired(key);
this.ensureType(key, 'set');
const membersSet = this.setStore.get(key)!;
@@ -297,7 +160,6 @@ export class MockKVProvider implements IKVProvider {
}
async srem(key: string, ...members: Array<string>): Promise<number> {
this.sremSpy(key, ...members);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
if (!membersSet) {
@@ -313,27 +175,23 @@ export class MockKVProvider implements IKVProvider {
}
async smembers(key: string): Promise<Array<string>> {
this.smembersSpy(key);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
return membersSet ? Array.from(membersSet) : [];
}
async sismember(key: string, member: string): Promise<number> {
this.sismemberSpy(key, member);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
return membersSet?.has(member) ? 1 : 0;
}
async scard(key: string): Promise<number> {
this.scardSpy(key);
this.evictIfExpired(key);
return this.setStore.get(key)?.size ?? 0;
}
async spop(key: string, count: number = 1): Promise<Array<string>> {
this.spopSpy(key, count);
private async spop(key: string, count: number = 1): Promise<Array<string>> {
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
if (!membersSet || count <= 0) {
@@ -353,7 +211,6 @@ export class MockKVProvider implements IKVProvider {
}
async zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number> {
this.zaddSpy(key, ...scoreMembers);
this.evictIfExpired(key);
this.ensureType(key, 'zset');
const members = this.zsetStore.get(key)!;
@@ -378,7 +235,6 @@ export class MockKVProvider implements IKVProvider {
}
async zrem(key: string, ...members: Array<string>): Promise<number> {
this.zremSpy(key, ...members);
this.evictIfExpired(key);
const zset = this.zsetStore.get(key);
if (!zset) {
@@ -394,7 +250,6 @@ export class MockKVProvider implements IKVProvider {
}
async zcard(key: string): Promise<number> {
this.zcardSpy(key);
this.evictIfExpired(key);
return this.zsetStore.get(key)?.size ?? 0;
}
@@ -405,25 +260,14 @@ export class MockKVProvider implements IKVProvider {
max: string | number,
...args: Array<string | number>
): Promise<Array<string>> {
this.zrangebyscoreSpy(key, min, max, ...args);
this.evictIfExpired(key);
const {limit: options} = parseRangeByScoreArguments(args);
const zset = this.zsetStore.get(key);
if (!zset) {
return [];
}
let offset = 0;
let limit = Number.POSITIVE_INFINITY;
for (let i = 0; i < args.length; i++) {
if (args[i] === 'LIMIT') {
const parsedOffset = Number(args[i + 1]);
const parsedLimit = Number(args[i + 2]);
if (Number.isFinite(parsedOffset) && Number.isFinite(parsedLimit)) {
offset = parsedOffset;
limit = parsedLimit;
}
break;
}
}
const offset = options?.offset ?? 0;
const limit = options?.count ?? Number.POSITIVE_INFINITY;
const minBound = parseScoreBound(min, true);
const maxBound = parseScoreBound(max, false);
const sortedMembers = Array.from(zset.entries()).sort((a, b) => {
@@ -439,8 +283,6 @@ export class MockKVProvider implements IKVProvider {
}
async rpush(key: string, ...values: Array<string>): Promise<number> {
this.rpushSpy(key, ...values);
this.rpushCalls.push({key, values});
this.evictIfExpired(key);
this.ensureType(key, 'list');
const list = this.listStore.get(key)!;
@@ -449,7 +291,6 @@ export class MockKVProvider implements IKVProvider {
}
async lpop(key: string, count: number = 1): Promise<Array<string>> {
this.lpopSpy(key, count);
this.evictIfExpired(key);
const list = this.listStore.get(key);
if (!list || count <= 0) {
@@ -463,7 +304,6 @@ export class MockKVProvider implements IKVProvider {
}
async llen(key: string): Promise<number> {
this.llenSpy(key);
this.evictIfExpired(key);
return this.listStore.get(key)?.length ?? 0;
}
@@ -489,7 +329,6 @@ export class MockKVProvider implements IKVProvider {
}
async hset(key: string, field: string, value: string): Promise<number> {
this.hsetSpy(key, field, value);
this.evictIfExpired(key);
this.ensureType(key, 'hash');
const hash = this.hashStore.get(key)!;
@@ -499,7 +338,6 @@ export class MockKVProvider implements IKVProvider {
}
async hdel(key: string, ...fields: Array<string>): Promise<number> {
this.hdelSpy(key, ...fields);
this.evictIfExpired(key);
const hash = this.hashStore.get(key);
if (!hash) {
@@ -515,7 +353,6 @@ export class MockKVProvider implements IKVProvider {
}
async hgetall(key: string): Promise<Record<string, string>> {
this.hgetallSpy(key);
this.evictIfExpired(key);
const hash = this.hashStore.get(key);
if (!hash) {
@@ -526,7 +363,7 @@ export class MockKVProvider implements IKVProvider {
async publish(channel: string, message: string): Promise<number> {
this.publishSpy(channel, message);
this.subscription.simulateMessage(channel, message);
this.subscription.deliver(channel, message);
return 1;
}
@@ -535,7 +372,6 @@ export class MockKVProvider implements IKVProvider {
}
async acquireLock(key: string, token: string, ttlSeconds: number): Promise<boolean> {
this.acquireLockSpy(key, token, ttlSeconds);
this.evictIfExpired(key);
if (this.keyExists(key)) {
return false;
@@ -547,7 +383,6 @@ export class MockKVProvider implements IKVProvider {
}
async releaseLock(key: string, token: string): Promise<boolean> {
this.releaseLockSpy(key, token);
this.evictIfExpired(key);
const currentToken = this.stringStore.get(key);
if (currentToken !== token) {
@@ -568,7 +403,6 @@ export class MockKVProvider implements IKVProvider {
}
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
this.checkLeakyBucketLimitSpy(key, limit, windowMs, cost);
this.evictIfExpired(key);
const nowMs = Date.now();
const capacity = Math.max(1, Math.floor(limit));
@@ -603,7 +437,6 @@ export class MockKVProvider implements IKVProvider {
}
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
this.scheduleBulkDeletionSpy(queueKey, secondaryKey, score, value);
await this.zadd(queueKey, score, value);
this.ensureType(secondaryKey, 'string');
this.stringStore.set(secondaryKey, value);
@@ -611,7 +444,6 @@ export class MockKVProvider implements IKVProvider {
}
async claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean> {
this.claimBulkDeletionSpy(queueKey, member, maxScore, leaseScore);
this.evictIfExpired(queueKey);
const score = this.zsetStore.get(queueKey)?.get(member);
if (score === undefined || score > maxScore) {
@@ -622,7 +454,6 @@ export class MockKVProvider implements IKVProvider {
}
async removeBulkDeletion(queueKey: string, secondaryKey: string, member = ''): Promise<boolean> {
this.removeBulkDeletionSpy(queueKey, secondaryKey, member);
this.evictIfExpired(secondaryKey);
if (member !== '' && (await this.zrem(queueKey, member)) === 1) {
this.deleteKey(secondaryKey);
@@ -648,7 +479,6 @@ export class MockKVProvider implements IKVProvider {
entries: Array<string>;
tokensConsumed: number;
}> {
this.dequeuePurgeBatchSpy(queueKey, bucketKey, maxItems, maxTokens, refillRate, refillIntervalMs);
const now = Date.now();
let tokens = maxTokens;
let lastRefill = now;
@@ -690,7 +520,6 @@ export class MockKVProvider implements IKVProvider {
keyCount: number,
...args: Array<string | number>
): Promise<unknown> {
this.evalScriptSpy(command, _script, keyCount, ...args);
if (keyCount !== 1) {
throw new Error(`Unsupported mock script key count: ${keyCount}`);
}
@@ -708,10 +537,6 @@ export class MockKVProvider implements IKVProvider {
}
async scan(pattern: string, count: number): Promise<Array<string>> {
const result = await this.scanSpy(pattern, count);
if (result !== undefined) {
return result;
}
const keys = this.getAllKeys().filter((key) => this.matchesPattern(key, pattern));
return keys.slice(0, count);
}
@@ -732,21 +557,9 @@ export class MockKVProvider implements IKVProvider {
return this.subscription;
}
setSubscription(subscription: MockKVSubscription): void {
this.subscription = subscription;
}
private createPipeline(): IKVPipeline {
const operations: Array<() => Promise<unknown>> = [];
const pipeline: IKVPipeline = {
get: (key: string) => {
operations.push(async () => await this.get(key));
return pipeline;
},
set: (key: string, value: string) => {
operations.push(async () => await this.set(key, value));
return pipeline;
},
setex: (key: string, ttlSeconds: number, value: string) => {
operations.push(async () => await this.setex(key, ttlSeconds, value));
return pipeline;
@@ -763,26 +576,10 @@ export class MockKVProvider implements IKVProvider {
operations.push(async () => await this.sadd(key, ...members));
return pipeline;
},
srem: (key: string, ...members: Array<string>) => {
operations.push(async () => await this.srem(key, ...members));
return pipeline;
},
zadd: (key: string, score: number, value: string) => {
operations.push(async () => await this.zadd(key, score, value));
return pipeline;
},
zrem: (key: string, ...members: Array<string>) => {
operations.push(async () => await this.zrem(key, ...members));
return pipeline;
},
hgetall: (key: string) => {
operations.push(async () => await this.hgetall(key));
return pipeline;
},
mset: (...args: Array<string>) => {
operations.push(async () => await this.mset(...args));
return pipeline;
},
exec: async () => {
const results: Array<[Error | null, unknown]> = [];
for (const operation of operations) {
@@ -988,16 +785,6 @@ export class MockKVProvider implements IKVProvider {
return deleted;
}
private setExpiryFromArgs(key: string, args: Array<string | number>): void {
const exIndex = args.indexOf('EX');
if (exIndex !== -1 && typeof args[exIndex + 1] === 'number') {
const ttlSeconds = args[exIndex + 1] as number;
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
return;
}
this.expiries.delete(key);
}
private getAllKeys(): Array<string> {
for (const key of this.expiries.keys()) {
this.evictIfExpired(key);
@@ -1021,7 +808,6 @@ export class MockKVProvider implements IKVProvider {
}
reset(): void {
this.rpushCalls = [];
this.stringStore.clear();
this.setStore.clear();
this.zsetStore.clear();
@@ -1030,44 +816,10 @@ export class MockKVProvider implements IKVProvider {
this.expiries.clear();
this.subscription.reset();
this.getSpy.mockClear();
this.setSpy.mockClear();
this.setexSpy.mockClear();
this.setnxSpy.mockClear();
this.mgetSpy.mockClear();
this.msetSpy.mockClear();
this.delSpy.mockClear();
this.existsSpy.mockClear();
this.expireSpy.mockClear();
this.ttlSpy.mockClear();
this.incrSpy.mockClear();
this.getexSpy.mockClear();
this.getdelSpy.mockClear();
this.saddSpy.mockClear();
this.sremSpy.mockClear();
this.smembersSpy.mockClear();
this.sismemberSpy.mockClear();
this.scardSpy.mockClear();
this.spopSpy.mockClear();
this.zaddSpy.mockClear();
this.zremSpy.mockClear();
this.zcardSpy.mockClear();
this.zrangebyscoreSpy.mockClear();
this.rpushSpy.mockClear();
this.lpopSpy.mockClear();
this.llenSpy.mockClear();
this.hsetSpy.mockClear();
this.hdelSpy.mockClear();
this.hgetallSpy.mockClear();
this.publishSpy.mockClear();
this.acquireLockSpy.mockClear();
this.releaseLockSpy.mockClear();
this.checkLeakyBucketLimitSpy.mockClear();
this.scheduleBulkDeletionSpy.mockClear();
this.claimBulkDeletionSpy.mockClear();
this.removeBulkDeletionSpy.mockClear();
this.scanSpy.mockClear();
this.dequeuePurgeBatchSpy.mockClear();
this.evalScriptSpy.mockClear();
}
}
Loaded 100 of 2982 files, more files were not shown because too many files have changed in this diff. Show more