mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 21:48:29 +09:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5d4a8bdb | ||
|
|
0ba15ee46a | ||
|
|
85d9ef7386 | ||
|
|
f8d85f0e5e | ||
|
|
7f674474b5 | ||
|
|
c9fdf8fead | ||
|
|
c17091768c | ||
|
|
e0640d68ff | ||
|
|
3292dc5f89 | ||
|
|
86e29b61e3 | ||
|
|
f8d3c70a91 | ||
|
|
74c6fad260 | ||
|
|
bcb30eea88 | ||
|
|
d59ddc2214 | ||
|
|
e87b593f46 | ||
|
|
5a2d81ebbb | ||
|
|
50c28aa8fa | ||
|
|
af7724dbf6 | ||
|
|
be314788b3 | ||
|
|
5b3f69bf27 | ||
|
|
d7aa6ec2aa | ||
|
|
20e3474393 | ||
|
|
3694300d86 | ||
|
|
54ec74e4f9 | ||
|
|
aa3b058ce6 | ||
|
|
44bc29b101 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -70,8 +70,6 @@ workloads:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
|
||||
@@ -661,11 +661,13 @@ services:
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
|
||||
Generated
+3
-4
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{guild_features, user_flags};
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -18,8 +22,13 @@ impl AdminApiClient {
|
||||
remove_flags: user_flags(remove_flags)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -34,8 +43,13 @@ impl AdminApiClient {
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_add_guild_members(
|
||||
@@ -48,8 +62,13 @@ impl AdminApiClient {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
@@ -60,8 +79,13 @@ impl AdminApiClient {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
@@ -89,29 +113,16 @@ impl AdminApiClient {
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::GuildFeatureSchema::from)
|
||||
.collect()
|
||||
}
|
||||
+17
-130
@@ -49,6 +49,15 @@ impl AdminApiClient {
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self {
|
||||
let generated = GeneratedClient::new_with_client(
|
||||
&config.api_endpoint,
|
||||
http_client.clone(),
|
||||
build_proxy_client_ip_headers(config),
|
||||
);
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||||
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||||
let query = query_params
|
||||
@@ -127,154 +136,32 @@ impl AdminApiClient {
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
pub async fn send<T, B>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.post_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed<T, B>(&self, path: &str, body: &B) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
self.post_typed_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
pub async fn send_void<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.post_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.patch_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
@@ -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}")))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryBranding {
|
||||
pub product_name: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub premium_product_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures {
|
||||
|
||||
impl InstancePremiumDiscovery {
|
||||
pub fn premium_product_name(&self) -> Option<&str> {
|
||||
self.app_public
|
||||
.branding
|
||||
.premium_product_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
non_blank(self.app_public.branding.premium_product_name.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PremiumBranding {
|
||||
pub name: Option<String>,
|
||||
pub premium_enabled: bool,
|
||||
pub product_name: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
}
|
||||
|
||||
impl PremiumBranding {
|
||||
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
|
||||
let branding = &discovery.app_public.branding;
|
||||
Self {
|
||||
name: discovery.premium_product_name().map(str::to_owned),
|
||||
premium_enabled: discovery.features.premium_enabled,
|
||||
product_name: non_blank(branding.product_name.as_deref()).map(str::to_owned),
|
||||
favicon_url: favicon_url(
|
||||
branding.favicon_url.as_deref(),
|
||||
branding.icon_url.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
|
||||
Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&config.app_public.branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
let branding = &config.app_public.branding;
|
||||
Self {
|
||||
product_name: non_blank(Some(&branding.product_name)).map(str::to_owned),
|
||||
favicon_url: favicon_url(
|
||||
branding.favicon_url.as_deref(),
|
||||
branding.icon_url.as_deref(),
|
||||
),
|
||||
..Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
|
||||
let name = name.trim();
|
||||
Self {
|
||||
name: (!name.is_empty()).then(|| name.to_owned()),
|
||||
name: non_blank(Some(name)).map(str::to_owned),
|
||||
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn favicon_url(favicon_url: Option<&str>, icon_url: Option<&str>) -> Option<String> {
|
||||
non_blank(favicon_url)
|
||||
.or_else(|| non_blank(icon_url))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -304,18 +328,52 @@ mod tests {
|
||||
PremiumBranding::from_discovery(&discovery),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true
|
||||
premium_enabled: true,
|
||||
product_name: Some("Example".to_owned()),
|
||||
favicon_url: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_branding_prefers_the_favicon_and_falls_back_to_the_icon() {
|
||||
let discovery = |branding| -> InstancePremiumDiscovery {
|
||||
serde_json::from_value(json!({"app_public": {"branding": branding}}))
|
||||
.expect("discovery")
|
||||
};
|
||||
let both = discovery(json!({
|
||||
"favicon_url": "https://media.example/favicon.png",
|
||||
"icon_url": "https://media.example/icon.png"
|
||||
}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&both)
|
||||
.favicon_url
|
||||
.as_deref(),
|
||||
Some("https://media.example/favicon.png")
|
||||
);
|
||||
let icon_only =
|
||||
discovery(json!({"favicon_url": " ", "icon_url": "https://media.example/icon.png"}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&icon_only)
|
||||
.favicon_url
|
||||
.as_deref(),
|
||||
Some("https://media.example/icon.png")
|
||||
);
|
||||
let neither = discovery(json!({"product_name": " "}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&neither),
|
||||
PremiumBranding::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_branding_from_instance_config_matches_discovery_rules() {
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false
|
||||
premium_enabled: false,
|
||||
..PremiumBranding::default()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
@@ -325,7 +383,8 @@ mod tests {
|
||||
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true
|
||||
premium_enabled: true,
|
||||
..PremiumBranding::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(®ion_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(®ion_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)
|
||||
|
||||
@@ -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 => {},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
api::types::{AdminUser, PremiumBranding},
|
||||
middleware::flash,
|
||||
session::{self, Session},
|
||||
state::AppState,
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
pub struct AuthContext {
|
||||
pub session: Session,
|
||||
pub admin_user: Option<AdminUser>,
|
||||
pub branding: Option<PremiumBranding>,
|
||||
}
|
||||
|
||||
pub async fn require_auth(
|
||||
@@ -28,27 +29,19 @@ pub async fn require_auth(
|
||||
let Some(session) = session else {
|
||||
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
|
||||
};
|
||||
let admin_result = fetch_admin_user(state.http_client(), config, &session).await;
|
||||
match admin_result {
|
||||
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
|
||||
AdminFetchResult::Unauthorized => {
|
||||
let login_url = format!("{}/login", config.base_path);
|
||||
return clear_session_and_redirect(&login_url, config);
|
||||
}
|
||||
AdminFetchResult::Ok(admin_user) => {
|
||||
let auth_context = AuthContext {
|
||||
session,
|
||||
admin_user: Some(*admin_user),
|
||||
};
|
||||
request.extensions_mut().insert(auth_context);
|
||||
}
|
||||
AdminFetchResult::None => {
|
||||
let auth_context = AuthContext {
|
||||
session,
|
||||
admin_user: None,
|
||||
};
|
||||
request.extensions_mut().insert(auth_context);
|
||||
}
|
||||
}
|
||||
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
|
||||
AdminFetchResult::None => None,
|
||||
};
|
||||
request.extensions_mut().insert(AuthContext {
|
||||
session,
|
||||
admin_user,
|
||||
branding: state.self_hosted_branding().await,
|
||||
});
|
||||
|
||||
let had_flash = flash::extract_flash(&request);
|
||||
if let Some(ref fd) = had_flash {
|
||||
@@ -64,42 +57,12 @@ pub async fn require_auth(
|
||||
response
|
||||
}
|
||||
|
||||
fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
config.base_path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_session_and_redirect(url: &str, config: &crate::config::AdminConfig) -> Response {
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
let admin_path = admin_cookie_path(config);
|
||||
for path in [admin_path, "/"] {
|
||||
if path != admin_path && admin_path == "/" {
|
||||
continue;
|
||||
}
|
||||
let clear = format!(
|
||||
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
crate::session::SESSION_COOKIE_NAME,
|
||||
path
|
||||
);
|
||||
if let Ok(v) = axum::http::HeaderValue::from_str(&clear) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, v);
|
||||
}
|
||||
let legacy_clear = format!(
|
||||
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
crate::session::LEGACY_SESSION_COOKIE_NAME,
|
||||
path
|
||||
);
|
||||
if let Ok(v) = axum::http::HeaderValue::from_str(&legacy_clear) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
session::append_cookies(
|
||||
&mut response,
|
||||
&session::clear_cookie_values(session::SESSION_COOKIE_NAME, config),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
config::AdminConfig,
|
||||
oauth2,
|
||||
session::{self, LEGACY_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_MAX_AGE},
|
||||
session::{
|
||||
self, SESSION_COOKIE_NAME, SESSION_MAX_AGE, admin_cookie_path, append_cookies,
|
||||
clear_cookie_values,
|
||||
},
|
||||
state::AppState,
|
||||
templates::pages::login,
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -87,7 +90,8 @@ async fn login_page(
|
||||
Some(_) => Some("Login error. Please try again."),
|
||||
None => None,
|
||||
};
|
||||
Html(login::login_page(config, error_msg).into_string()).into_response()
|
||||
let branding = state.self_hosted_branding().await;
|
||||
Html(login::login_page(config, branding.as_ref(), error_msg).into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn auth_start(State(state): State<AppState>) -> Response {
|
||||
@@ -201,7 +205,7 @@ async fn oauth2_callback_finish(
|
||||
secure
|
||||
);
|
||||
|
||||
let mut cookies = clear_session_cookie_values(config);
|
||||
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
|
||||
cookies.push(session_cookie);
|
||||
oauth_finish_response(StatusCode::OK, &format!("{base}/dashboard"), &cookies)
|
||||
}
|
||||
@@ -215,7 +219,7 @@ async fn logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -
|
||||
}
|
||||
redirect_with_cookies(
|
||||
&format!("{}/login", config.base_path),
|
||||
&clear_session_cookie_values(config),
|
||||
&clear_cookie_values(SESSION_COOKIE_NAME, config),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,45 +362,6 @@ pub(crate) fn json_string(value: &str) -> String {
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
}
|
||||
|
||||
fn admin_cookie_path(config: &AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
config.base_path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_cookie_value(name: &str, path: &str) -> String {
|
||||
format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0")
|
||||
}
|
||||
|
||||
fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec<String> {
|
||||
let admin_path = admin_cookie_path(config);
|
||||
let mut cookies = vec![clear_cookie_value(name, admin_path)];
|
||||
if admin_path != "/" {
|
||||
cookies.push(clear_cookie_value(name, "/"));
|
||||
}
|
||||
cookies
|
||||
}
|
||||
|
||||
fn clear_session_cookie_values(config: &AdminConfig) -> Vec<String> {
|
||||
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
|
||||
cookies.extend(clear_cookie_values(LEGACY_SESSION_COOKIE_NAME, config));
|
||||
cookies
|
||||
}
|
||||
|
||||
fn append_cookie(response: &mut Response, cookie: &str) {
|
||||
if let Ok(value) = HeaderValue::from_str(cookie) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_cookies(response: &mut Response, cookies: &[String]) {
|
||||
for cookie in cookies {
|
||||
append_cookie(response, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_with_cookies(url: &str, cookies: &[String]) -> Response {
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
append_cookies(&mut response, cookies);
|
||||
|
||||
@@ -1,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(),
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -470,31 +470,3 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
|
||||
url.push_str(&urlencoding::encode(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
|
||||
let gold = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
|
||||
let unnamed = PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(
|
||||
premium_badge_name(Some(&unnamed)).as_deref(),
|
||||
Some("Premium")
|
||||
);
|
||||
let everyone = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&everyone)), None);
|
||||
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -88,6 +88,17 @@ impl AppState {
|
||||
Some(branding)
|
||||
}
|
||||
|
||||
pub async fn self_hosted_branding(&self) -> Option<PremiumBranding> {
|
||||
if !self.config().self_hosted {
|
||||
return None;
|
||||
}
|
||||
self.premium_branding(&AdminApiClient::anonymous(
|
||||
self.http_client(),
|
||||
self.config(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
|
||||
self.account_identity_settings(client).await.mode
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{api::types::FlashLevel, config::AdminConfig, middleware::auth::AuthContext};
|
||||
use crate::{
|
||||
api::types::{FlashLevel, PremiumBranding},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
};
|
||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
|
||||
use super::components::drawer::drawer_controller_script;
|
||||
@@ -11,6 +15,28 @@ use super::layout_scripts::{
|
||||
};
|
||||
use super::layout_sidebar::render_sidebar;
|
||||
|
||||
const DEFAULT_PRODUCT_NAME: &str = "Fluxer";
|
||||
|
||||
pub fn product_name(branding: Option<&PremiumBranding>) -> &str {
|
||||
branding
|
||||
.and_then(|branding| branding.product_name.as_deref())
|
||||
.unwrap_or(DEFAULT_PRODUCT_NAME)
|
||||
}
|
||||
|
||||
pub fn favicon_links(config: &AdminConfig, branding: Option<&PremiumBranding>) -> Markup {
|
||||
let cdn = &config.static_cdn_endpoint;
|
||||
html! {
|
||||
@if let Some(favicon_url) = branding.and_then(|branding| branding.favicon_url.as_deref()) {
|
||||
link rel="icon" href=(favicon_url);
|
||||
} @else {
|
||||
link rel="icon" type="image/x-icon" href={(cdn) "/web/favicon.ico"};
|
||||
link rel="apple-touch-icon" href={(cdn) "/web/apple-touch-icon.png"};
|
||||
link rel="icon" type="image/png" sizes="32x32" href={(cdn) "/web/favicon-32x32.png"};
|
||||
link rel="icon" type="image/png" sizes="16x16" href={(cdn) "/web/favicon-16x16.png"};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String {
|
||||
format!("{base_path}{path}?t={asset_version}")
|
||||
}
|
||||
@@ -72,10 +98,7 @@ pub fn admin_layout_ext(
|
||||
title { (title) " ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
|
||||
link rel="icon" type="image/png" sizes="32x32" href={(config.static_cdn_endpoint) "/web/favicon-32x32.png"};
|
||||
link rel="icon" type="image/png" sizes="16x16" href={(config.static_cdn_endpoint) "/web/favicon-16x16.png"};
|
||||
(favicon_links(config, auth.branding.as_ref()))
|
||||
script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {}
|
||||
}
|
||||
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
||||
|
||||
@@ -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("← 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 →"))
|
||||
}
|
||||
} @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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,9 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::AdminConfig;
|
||||
use crate::{
|
||||
api::types::PremiumBranding,
|
||||
config::AdminConfig,
|
||||
templates::layout::{favicon_links, product_name},
|
||||
};
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
|
||||
pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
pub fn login_page(
|
||||
config: &AdminConfig,
|
||||
branding: Option<&PremiumBranding>,
|
||||
error_message: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
@@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
title { "Login ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href={(base) "/static/app.css"};
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
(favicon_links(config, branding))
|
||||
}
|
||||
body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" {
|
||||
main class="w-full max-w-sm" {
|
||||
@@ -38,7 +46,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
bg-neutral-900 text-white hover:bg-neutral-800 \
|
||||
px-4 py-2 text-base w-full sm:w-fit \
|
||||
focus:ring-offset-white" {
|
||||
span { "Sign in with Fluxer" }
|
||||
span { "Sign in with " (product_name(branding)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::report_category::{
|
||||
message_lookup_href, report_status, report_type, reported_guild_icon,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
@@ -22,7 +26,7 @@ use crate::{
|
||||
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button,
|
||||
opt_out_checkbox, select_chevron,
|
||||
},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
media::user_avatar_url,
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
nsfw_indicators::{
|
||||
@@ -50,11 +54,7 @@ pub struct LiveProfile {
|
||||
}
|
||||
|
||||
fn status_badge(status: i32) -> Markup {
|
||||
let (label, variant) = match status {
|
||||
0 => ("Pending", BadgeVariant::Warning),
|
||||
1 => ("Resolved", BadgeVariant::Success),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
};
|
||||
let (label, variant) = report_status(status);
|
||||
html! {
|
||||
span class="inline-flex w-fit self-start" {
|
||||
(badge(label, variant))
|
||||
@@ -62,15 +62,6 @@ fn status_badge(status: i32) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn report_type_label(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter_label(report: &ReportEntry) -> String {
|
||||
if let Some(tag) = &report.reporter_tag {
|
||||
return tag.to_owned();
|
||||
@@ -116,7 +107,7 @@ fn basic_info_section(config: &AdminConfig, auth: &AuthContext, report: &ReportE
|
||||
div class="grid grid-cols-1 sm:grid-cols-2 gap-4" {
|
||||
(data_field_mono("Report ID", &report.report_id))
|
||||
(data_field_text("Reported At", &format_admin_timestamp(&report.reported_at)))
|
||||
(data_field_text("Type", report_type_label(report.report_type)))
|
||||
(data_field_text("Type", report_type(report.report_type).0))
|
||||
(category_field(report))
|
||||
(reason_field(report))
|
||||
@if let Some(ref reporter_id) = report.reporter_id {
|
||||
@@ -237,18 +228,6 @@ fn answer_steps(flow: &ReportFlowAnswersEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn message_lookup_href(base: &str, channel_id: &str, message_id: Option<&str>) -> String {
|
||||
let mut href = format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
urlencoding::encode(channel_id)
|
||||
);
|
||||
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
|
||||
href.push_str("&message_id=");
|
||||
href.push_str(&urlencoding::encode(message_id));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn snapshot_note() -> Markup {
|
||||
html! {
|
||||
span class="text-neutral-500 text-xs italic" { "(at time of report)" }
|
||||
@@ -484,30 +463,6 @@ fn reported_guild_field(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_guild_icon(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
guild_id: &str,
|
||||
label: &str,
|
||||
) -> Markup {
|
||||
match guild_icon_url(
|
||||
config,
|
||||
guild_id,
|
||||
report.reported_guild_icon_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn additional_info_section(report: &ReportEntry) -> Markup {
|
||||
html! {
|
||||
@if let Some(ref info) = report.additional_info {
|
||||
@@ -1182,7 +1137,7 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
||||
div class="grid grid-cols-1 sm:grid-cols-2 gap-4" {
|
||||
(data_field_mono("Report ID", &report.report_id))
|
||||
(data_field_text("Reported At", &format_admin_timestamp(&report.reported_at)))
|
||||
(data_field_text("Type", report_type_label(report.report_type)))
|
||||
(data_field_text("Type", report_type(report.report_type).0))
|
||||
(category_field(report))
|
||||
(reason_field(report))
|
||||
@if let Some(ref reporter_id) = report.reporter_id {
|
||||
@@ -1195,14 +1150,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_form_shares_the_comment_by_default_and_says_the_reporter_is_always_told() {
|
||||
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
|
||||
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::templates::components::report_category::{
|
||||
message_lookup_href, report_status, report_type, reported_guild_icon,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, ReportReasonEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
@@ -9,7 +15,7 @@ use crate::{
|
||||
badge::{BadgeVariant, badge},
|
||||
drawer::{DrawerSide, DrawerWidth, drawer},
|
||||
form::{secondary_button_link, select_input, submit_button, text_input},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
media::user_avatar_url,
|
||||
page_container::page_header_with_actions,
|
||||
report_category::{reason_repeats_category, report_category, report_category_options},
|
||||
report_webhook::{
|
||||
@@ -186,23 +192,6 @@ fn reason_select(reasons: &[ReportReasonEntry], selected: &str) -> Markup {
|
||||
select_input("reason", "Reason", &options, selected)
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
match status {
|
||||
0 => ("Pending", BadgeVariant::Warning),
|
||||
1 => ("Resolved", BadgeVariant::Success),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
match report_type {
|
||||
0 => ("Message", BadgeVariant::Info),
|
||||
1 => ("User", BadgeVariant::Default),
|
||||
2 => ("Guild", BadgeVariant::Warning),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(username) = &report.reporter_username {
|
||||
return tagged_user(
|
||||
@@ -245,18 +234,6 @@ fn reported_user_label(report: &ReportEntry) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn message_lookup_href(base: &str, channel_id: &str, message_id: Option<&str>) -> String {
|
||||
let mut href = format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
urlencoding::encode(channel_id)
|
||||
);
|
||||
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
|
||||
href.push_str("&message_id=");
|
||||
href.push_str(&urlencoding::encode(message_id));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn reporter_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let primary = reporter_label(report);
|
||||
@@ -341,30 +318,6 @@ fn reported_guild_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_guild_icon(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
guild_id: &str,
|
||||
label: &str,
|
||||
) -> Markup {
|
||||
match guild_icon_url(
|
||||
config,
|
||||
guild_id,
|
||||
report.reported_guild_icon_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let channel_label = report
|
||||
@@ -460,7 +413,7 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
|
||||
(format_admin_timestamp(&report.reported_at))
|
||||
}
|
||||
@let (type_label, type_variant) = format_report_type(report.report_type);
|
||||
@let (type_label, type_variant) = report_type(report.report_type);
|
||||
(badge(type_label, type_variant))
|
||||
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
@let repeats = report.category.as_deref().zip(reason_label).is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
@@ -495,7 +448,7 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
|
||||
(reported_cell(config, report))
|
||||
}
|
||||
@let (status_label, status_variant) = format_status(report.status);
|
||||
@let (status_label, status_variant) = report_status(report.status);
|
||||
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
|
||||
span data-status-pill=(report.report_id) {
|
||||
(badge(status_label, status_variant))
|
||||
@@ -567,29 +520,12 @@ fn reports_pagination(
|
||||
let next_page = page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
html! {
|
||||
div class="mt-4 flex items-center justify-between" {
|
||||
@if page > 0 {
|
||||
a href=(reports_url(config, filters, page - 1, limit))
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
(PreEscaped("← 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 →"))
|
||||
}
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pager_with_missing(
|
||||
(page > 0).then(|| reports_url(config, filters, page - 1, limit)),
|
||||
Some(format!("Page {} of {total_pages}", u64::from(page) + 1)),
|
||||
next_page.map(|page| reports_url(config, filters, page, limit)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn push_param(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::admin_layout,
|
||||
layout::{admin_layout, product_name},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
@@ -37,9 +37,9 @@ pub fn system_dm_page(
|
||||
"Send a system DM"
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Sent from the official Fluxer system account. Each recipient \
|
||||
will receive the same content as a DM. Progress is observable \
|
||||
on the "
|
||||
"Sent from the official " (product_name(auth.branding.as_ref()))
|
||||
" system account. Each recipient will receive the same content \
|
||||
as a DM. Progress is observable on the "
|
||||
a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" {
|
||||
"Jobs page"
|
||||
}
|
||||
|
||||
@@ -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&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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&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¬ify_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(),
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Loaded 100 of 3007 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user