mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 21:48:29 +09:00
Compare commits
33
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 | ||
|
|
b07dcc368d | ||
|
|
0967bf3136 | ||
|
|
edc913096a | ||
|
|
cca8227ca6 | ||
|
|
45b78a13c2 | ||
|
|
6acd7f3d22 | ||
|
|
7d2c51a57f |
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
+19
-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",
|
||||
@@ -1803,6 +1792,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -1834,7 +1824,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
@@ -1859,6 +1848,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"clap",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"p256",
|
||||
@@ -1899,6 +1889,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"libc",
|
||||
"rmp-serde",
|
||||
@@ -2018,12 +2009,16 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.5.0",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
@@ -2892,6 +2887,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3304,34 +3308,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -3701,26 +3677,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
||||
Vendored
+1
-1
@@ -104,7 +104,7 @@ FLUXER_EMAIL_SMTP_HOST=mailpit
|
||||
FLUXER_EMAIL_SMTP_PORT=1025
|
||||
FLUXER_EMAIL_SMTP_USERNAME=dev
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=dev
|
||||
FLUXER_EMAIL_SMTP_SECURE=false
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=none
|
||||
FLUXER_SEARCH_ENGINE=meilisearch
|
||||
FLUXER_SEARCH_URL=http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
|
||||
|
||||
@@ -70,8 +70,6 @@ workloads:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
|
||||
@@ -318,7 +318,13 @@ FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=
|
||||
FLUXER_EMAIL_SMTP_SECURE=true
|
||||
# How the SMTP connection is encrypted: implicit, starttls, opportunistic or
|
||||
# none. Leave it empty for implicit TLS on port 465 and required STARTTLS on any
|
||||
# other port.
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
@@ -630,7 +636,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
|
||||
|
||||
# app-proxy index upstream and manifest scope.
|
||||
# app-proxy discovery refresh, index upstream and manifest scope.
|
||||
#DISCOVERY_REFRESH_INTERVAL_MS=60000
|
||||
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
|
||||
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
|
||||
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
|
||||
@@ -121,6 +121,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
@@ -660,6 +661,15 @@ services:
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
|
||||
Generated
+69
-17
@@ -3074,7 +3074,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
|
||||
"description": "Permanently deletes a guild. The guild becomes unavailable at once, and a background job deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4144,10 +4144,10 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}}
|
||||
}
|
||||
}
|
||||
@@ -11107,7 +11107,7 @@
|
||||
"category": {"type": "string"},
|
||||
"scope": {"type": "string"},
|
||||
"isToggle": {"type": "boolean"},
|
||||
"unit": {"type": "string", "enum": ["bytes", "count"]},
|
||||
"unit": {"type": "string", "enum": ["bytes", "count", "bps"]},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"}
|
||||
},
|
||||
@@ -11154,6 +11154,7 @@
|
||||
"max_private_channels_per_user",
|
||||
"max_reactions_per_message",
|
||||
"max_relationships",
|
||||
"max_screen_share_bitrate",
|
||||
"max_users_per_message_reaction",
|
||||
"max_voice_message_duration",
|
||||
"max_webhooks_per_channel",
|
||||
@@ -11523,9 +11524,34 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string"},
|
||||
"password_set": {"type": "boolean"},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "Saved TLS mode, or null to pick one from the port",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"effective_tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "username", "password_set", "secure"],
|
||||
"required": ["host", "port", "username", "password_set", "tls_mode", "effective_tls_mode"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"disable_new_ip_authorization": {"type": "boolean"},
|
||||
@@ -11788,13 +11814,24 @@
|
||||
"InstanceEmailSmtpTestRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"secure": {"default": true, "type": "boolean"}
|
||||
},
|
||||
"required": ["host", "port", "username", "password"]
|
||||
"host": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"InstanceEmailSmtpTestResponse": {
|
||||
"type": "object",
|
||||
@@ -11869,7 +11906,11 @@
|
||||
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
|
||||
"theme_color": {
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"pattern": "^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$"
|
||||
},
|
||||
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
|
||||
@@ -11923,7 +11964,20 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"secure": {"description": "Deprecated, use tls_mode", "nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"disable_new_ip_authorization": {"nullable": true, "type": "boolean"}
|
||||
@@ -17218,7 +17272,6 @@
|
||||
"ACCOUNT_TOO_NEW_FOR_GUILD",
|
||||
"ACLS_MUST_BE_NON_EMPTY",
|
||||
"ADMIN_API_KEY_NOT_FOUND",
|
||||
"APPLICATION_NOT_OWNED",
|
||||
"ALREADY_FRIENDS",
|
||||
"BOTS_CANNOT_CREATE_GUILDS",
|
||||
"BOTS_CANNOT_SEND_FRIEND_REQUESTS",
|
||||
@@ -17514,7 +17567,6 @@
|
||||
"Your account is too new to send messages in this community.",
|
||||
"ACLs must be non-empty.",
|
||||
"Admin API key wasn't found.",
|
||||
"You don't own this application.",
|
||||
"You're already friends with this user.",
|
||||
"Bots can't create communities.",
|
||||
"Bots can't send friend requests.",
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -30,33 +32,43 @@ impl AdminApiClient {
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.patch_typed_with_reason("/admin/instance/config", update, None)
|
||||
self.send(Method::PATCH, "/admin/instance/config", Some(update), None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn test_instance_smtp_config(
|
||||
pub async fn test_saved_instance_smtp_config(
|
||||
&self,
|
||||
request: &InstanceEmailSmtpTestRequest,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
self.post_typed("/admin/instance/config/smtp-tests", request)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/instance/config/smtp-tests",
|
||||
Some(&serde_json::json!({})),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_registration_url(
|
||||
&self,
|
||||
request: &CreateRegistrationUrlRequest,
|
||||
) -> ApiResult<CreateRegistrationUrlResponse> {
|
||||
self.post_typed("/admin/instance/registration-urls", request)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/instance/registration-urls",
|
||||
Some(request),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
|
||||
self.delete_with_reason(
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/instance/registration-urls/{}",
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
None,
|
||||
None::<&serde_json::Value>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -82,7 +94,8 @@ impl AdminApiClient {
|
||||
status: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let body = serde_json::json!({"status": status});
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,7 +229,8 @@ pub struct InstanceEmailSmtpIntegrationResponse {
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password_set: bool,
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<String>,
|
||||
pub effective_tls_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -873,7 +874,7 @@ pub struct InstanceEmailSmtpIntegrationUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -928,15 +929,6 @@ pub struct InstanceAttachmentDecayUpdateRequest {
|
||||
pub renew_window_days: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestRequest {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestResponse {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -143,7 +143,7 @@ pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_message(error: &ApiError) -> Option<String> {
|
||||
pub(super) fn validation_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,11 +14,11 @@ use crate::{
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
@@ -233,21 +233,19 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"test_smtp" => match build_smtp_test_request(&form) {
|
||||
Ok(request) => match client.test_instance_smtp_config(&request).await {
|
||||
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
|
||||
Ok(response) => FlashData::error(
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "SMTP validation failed".to_owned()),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
||||
FlashData::error("Failed to validate SMTP configuration")
|
||||
}
|
||||
},
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"test_smtp" => {
|
||||
let update = build_integrations_update(&form);
|
||||
match client.update_instance_config(&update).await {
|
||||
Ok(_) => match client.test_saved_instance_smtp_config().await {
|
||||
Ok(response) => smtp_test_flash(response),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
||||
FlashData::error("Settings saved, but the SMTP test could not run")
|
||||
}
|
||||
},
|
||||
Err(error) => instance_config_result(Err::<(), _>(error)),
|
||||
}
|
||||
}
|
||||
"disable_single_community" => {
|
||||
let update = build_single_community_update(false);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
@@ -405,16 +403,33 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
|
||||
fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) -> FlashData {
|
||||
match result {
|
||||
Ok(_) => FlashData::success("Instance config updated"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update instance config");
|
||||
FlashData::error("Failed to update instance config")
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => {
|
||||
FlashData::error(format!("Failed to update instance config: {message}"))
|
||||
}
|
||||
None => FlashData::error("Failed to update instance config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp_test_flash(response: InstanceEmailSmtpTestResponse) -> FlashData {
|
||||
if response.ok {
|
||||
return FlashData::success("Settings saved and SMTP connection verified");
|
||||
}
|
||||
let reason = response
|
||||
.error
|
||||
.unwrap_or_else(|| "the server gave no reason".to_owned());
|
||||
FlashData::error(format!(
|
||||
"Settings saved, but the SMTP test failed: {reason}"
|
||||
))
|
||||
}
|
||||
|
||||
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let flag = |key: &str| form.bool_value(key);
|
||||
let get = |key: &str| Some(form.clean(key));
|
||||
@@ -891,7 +906,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
port: smtp_port,
|
||||
username: clean("integration_smtp_username"),
|
||||
password: clean("integration_smtp_password"),
|
||||
secure: Some(form.bool_value("integration_smtp_secure")),
|
||||
tls_mode: form
|
||||
.first("integration_smtp_tls_mode")
|
||||
.map(|value| Some(value.trim().to_owned()).filter(|mode| !mode.is_empty())),
|
||||
}),
|
||||
disable_new_ip_authorization: Some(
|
||||
form.bool_value("integration_email_disable_new_ip_authorization"),
|
||||
@@ -934,29 +951,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTestRequest, String> {
|
||||
let host = form
|
||||
.clean("integration_smtp_host")
|
||||
.ok_or_else(|| "SMTP host is required".to_owned())?;
|
||||
let port = form
|
||||
.first("integration_smtp_port")
|
||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
||||
.ok_or_else(|| "SMTP port must be between 1 and 65535".to_owned())?;
|
||||
let username = form
|
||||
.clean("integration_smtp_username")
|
||||
.ok_or_else(|| "SMTP username is required".to_owned())?;
|
||||
let password = form
|
||||
.clean("integration_smtp_password")
|
||||
.ok_or_else(|| "SMTP password is required for validation".to_owned())?;
|
||||
Ok(InstanceEmailSmtpTestRequest {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
secure: form.bool_value("integration_smtp_secure"),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
@@ -1042,6 +1036,7 @@ fn update_limit_rule_values(
|
||||
rule: &mut LimitRule,
|
||||
form: &MultiValueForm,
|
||||
limit_keys: &[String],
|
||||
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
|
||||
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
||||
) {
|
||||
let mut limits = std::collections::BTreeMap::new();
|
||||
@@ -1055,12 +1050,19 @@ fn update_limit_rule_values(
|
||||
{
|
||||
limits.extend(defaults.clone());
|
||||
}
|
||||
for key in limit_keys {
|
||||
if metadata.get(key).is_some_and(|metadata| metadata.is_toggle)
|
||||
&& rule.limits.contains_key(key)
|
||||
{
|
||||
limits.entry(key.clone()).or_insert(0);
|
||||
}
|
||||
}
|
||||
rule.limits = limits;
|
||||
rule.filters = build_limit_filters(form);
|
||||
}
|
||||
|
||||
fn limit_config_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
fn limit_config_result<T>(
|
||||
result: Result<T, crate::api::client::ApiError>,
|
||||
success_message: &'static str,
|
||||
error_message: &'static str,
|
||||
) -> FlashData {
|
||||
@@ -1068,7 +1070,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
|
||||
Ok(_) => FlashData::success(success_message),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update limit config");
|
||||
FlashData::error(error_message)
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => FlashData::error(format!("{error_message}: {message}")),
|
||||
None => FlashData::error(error_message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1137,7 +1142,13 @@ pub async fn limit_config_post(
|
||||
.defaults
|
||||
.get(&rule_id)
|
||||
.or_else(|| current.defaults.get("default"));
|
||||
update_limit_rule_values(rule, &form, ¤t.limit_keys, fallback);
|
||||
update_limit_rule_values(
|
||||
rule,
|
||||
&form,
|
||||
¤t.limit_keys,
|
||||
¤t.metadata,
|
||||
fallback,
|
||||
);
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
@@ -1147,6 +1158,43 @@ pub async fn limit_config_post(
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"reset" => {
|
||||
let rule = aq
|
||||
.rule
|
||||
.as_deref()
|
||||
.and_then(clean_string)
|
||||
.and_then(|rule_id| {
|
||||
limit_config
|
||||
.rules
|
||||
.iter_mut()
|
||||
.find(|rule| rule.id == rule_id)
|
||||
});
|
||||
let Some(rule) = rule else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let Some(defaults) = current.defaults.get(&rule.id) else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("This rule has no defaults to reset to"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
rule.limits = defaults.clone();
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
result,
|
||||
"Limit rule reset to defaults",
|
||||
"Failed to reset limit rule",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
Some(rule_id) => rule_id,
|
||||
@@ -1310,6 +1358,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_integrations_update_sends_the_smtp_tls_mode_only_when_the_form_has_it() {
|
||||
let smtp_update = |body: &[u8]| {
|
||||
build_integrations_update(&MultiValueForm::parse(body))
|
||||
.integrations
|
||||
.and_then(|integrations| integrations.email)
|
||||
.and_then(|email| email.smtp)
|
||||
.expect("smtp update")
|
||||
};
|
||||
let body = |update| serde_json::to_value(update).expect("serialize smtp update");
|
||||
|
||||
let chosen = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=none");
|
||||
assert_eq!(body(chosen), serde_json::json!({"tls_mode": "none"}));
|
||||
|
||||
let automatic = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=");
|
||||
assert_eq!(body(automatic), serde_json::json!({"tls_mode": null}));
|
||||
|
||||
let from_an_older_page =
|
||||
smtp_update(b"integration_email_present=1&integration_smtp_secure=true");
|
||||
assert_eq!(body(from_an_older_page), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sso_update_keeps_repeated_allowed_domains() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1345,129 +1415,62 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_limit_filters_accepts_repeated_and_delimited_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"traits%5B%5D=staff&traits%5B%5D=partner%2Cvip&guild_features=COMMUNITY%0ANEWS",
|
||||
);
|
||||
let filters = build_limit_filters(&form).expect("filters");
|
||||
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
|
||||
let toggle = |key: &str, is_toggle: bool| {
|
||||
(
|
||||
key.to_owned(),
|
||||
LimitKeyMetadata {
|
||||
key: key.to_owned(),
|
||||
label: key.to_owned(),
|
||||
description: String::new(),
|
||||
category: "features".to_owned(),
|
||||
scope: "user".to_owned(),
|
||||
is_toggle,
|
||||
unit: None,
|
||||
min: None,
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let metadata = std::collections::BTreeMap::from([
|
||||
toggle("feature_animated_avatar", true),
|
||||
toggle("feature_animated_banner", true),
|
||||
toggle("max_guilds", false),
|
||||
]);
|
||||
let limit_keys = metadata.keys().cloned().collect::<Vec<_>>();
|
||||
let mut rule = LimitRule {
|
||||
id: "premium".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 1),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 200),
|
||||
]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form =
|
||||
MultiValueForm::parse(b"traits=premium&feature_animated_banner=1&max_guilds=250");
|
||||
update_limit_rule_values(&mut rule, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
filters.traits,
|
||||
vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()]
|
||||
rule.limits,
|
||||
std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 0),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 250),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
filters.guild_features,
|
||||
vec!["COMMUNITY".to_owned(), "NEWS".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
let mut custom = LimitRule {
|
||||
id: "vip".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form = MultiValueForm::parse(b"traits=vip&max_guilds=300");
|
||||
update_limit_rule_values(&mut custom, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_splits_uppercases_and_dedupes() {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" se ,NO\nbr\r\n fi\tSE ,, no ").expect("valid codes"),
|
||||
vec![
|
||||
"SE".to_owned(),
|
||||
"NO".to_owned(),
|
||||
"BR".to_owned(),
|
||||
"FI".to_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(" \n ").expect("valid codes"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_malformed_codes() {
|
||||
for value in ["S", "SWE", "S1", "5E", "<>", "ÅL"] {
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("SE,{value}")).expect_err("invalid code"),
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_country_codes_rejects_exceeding_the_cap() {
|
||||
let codes = (b'A'..=b'Z')
|
||||
.flat_map(|first| {
|
||||
(b'A'..=b'Z').map(move |second| format!("{}{}", first as char, second as char))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let at_cap = codes[..EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES].join(" ");
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!("{at_cap} aa"))
|
||||
.expect("valid codes at cap")
|
||||
.len(),
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES
|
||||
);
|
||||
assert_eq!(
|
||||
parse_experiment_country_codes(&format!(
|
||||
"{at_cap} {}",
|
||||
codes[EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES]
|
||||
))
|
||||
.expect_err("too many codes"),
|
||||
"Rollout countries must contain at most 250 unique codes"
|
||||
custom.limits,
|
||||
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1531,54 +1534,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
@@ -1620,45 +1575,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_cost=20001",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_max_counter=99",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_max_counter=20001",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
);
|
||||
assert_eq!(
|
||||
build_domain_migration_update(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1711,38 +1627,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_p2p_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_p2p_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_p2p_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_p2p_rollout_country_codes=SE%2CSWE",
|
||||
"Rollout countries entry 2 must be a two-letter country code",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=5",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
(
|
||||
"voice_p2p_max_participants=1",
|
||||
"Maximum participants must be a whole number between 2 and 4",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_p2p_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
@@ -1753,67 +1637,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_rejects_invalid_numbers() {
|
||||
for (key, message, below_min, above_max) in [
|
||||
(
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Poll interval must be a whole number between 60 and 86400",
|
||||
"59",
|
||||
"86401",
|
||||
),
|
||||
(
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Poll jitter must be a whole number between 0 and 50",
|
||||
"-1",
|
||||
"51",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
below_min,
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_experiment_delivery_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
|
||||
for (interval, jitter) in [(60, 0), (86_400, 50)] {
|
||||
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
|
||||
let request = build_experiment_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.expect("valid form")
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, Some(900));
|
||||
assert_eq!(update.poll_jitter_percent, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_limit_rule_values_reads_checked_limit_keys() {
|
||||
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
|
||||
@@ -1823,7 +1646,13 @@ mod tests {
|
||||
limits: std::collections::BTreeMap::new(),
|
||||
modified_fields: None,
|
||||
};
|
||||
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
|
||||
update_limit_rule_values(
|
||||
&mut rule,
|
||||
&form,
|
||||
&["message_send".to_owned()],
|
||||
&std::collections::BTreeMap::new(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
||||
assert_eq!(
|
||||
rule.filters.expect("filters").traits,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ pub fn danger_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn secondary_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", SECONDARY_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn form_field_group(
|
||||
label: &str,
|
||||
name: &str,
|
||||
@@ -238,3 +242,28 @@ pub fn secondary_button_link(label: &str, href: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn form_field_with_id(
|
||||
label: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
input_type: &str,
|
||||
value: &str,
|
||||
placeholder: &str,
|
||||
required: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
label for=(id) class="block text-sm font-medium text-neutral-700" {
|
||||
(label)
|
||||
}
|
||||
input type=(input_type) id=(id) name=(name) value=(value)
|
||||
placeholder=(placeholder) required[required]
|
||||
step=[if input_type == "number" { Some("any") } else { None }]
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,5 @@ pub mod tooltip;
|
||||
pub mod user_display;
|
||||
pub mod user_profile_badges;
|
||||
pub mod voice;
|
||||
|
||||
pub mod pagination;
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn pager(prev: Option<String>, label: Option<String>, next: Option<String>) -> Markup {
|
||||
pager_with_missing(prev, label, next, false)
|
||||
}
|
||||
|
||||
pub fn pager_with_missing(
|
||||
prev: Option<String>,
|
||||
label: Option<String>,
|
||||
next: Option<String>,
|
||||
omit_missing: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="mt-4 flex items-center justify-between gap-3" {
|
||||
@if let Some(url) = prev {
|
||||
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "← Previous" }
|
||||
} @else if !omit_missing {
|
||||
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "← Previous" }
|
||||
}
|
||||
@if let Some(text) = label { span class="text-sm text-neutral-500" { (text) } }
|
||||
@if let Some(url) = next {
|
||||
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "Next →" }
|
||||
} @else if !omit_missing {
|
||||
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "Next →" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::BadgeVariant,
|
||||
media::{guild_icon_url, initials},
|
||||
},
|
||||
};
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
@@ -51,3 +60,60 @@ pub fn report_category(category: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
match status {
|
||||
0 => ("Pending", BadgeVariant::Warning),
|
||||
1 => ("Resolved", BadgeVariant::Success),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
match report_type {
|
||||
0 => ("Message", BadgeVariant::Info),
|
||||
1 => ("User", BadgeVariant::Default),
|
||||
2 => ("Guild", BadgeVariant::Warning),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn message_lookup_href(
|
||||
base: &str,
|
||||
channel_id: &str,
|
||||
message_id: Option<&str>,
|
||||
) -> String {
|
||||
let mut href = format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
urlencoding::encode(channel_id)
|
||||
);
|
||||
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
|
||||
href.push_str("&message_id=");
|
||||
href.push_str(&urlencoding::encode(message_id));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
pub(crate) fn reported_guild_icon(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
guild_id: &str,
|
||||
label: &str,
|
||||
) -> Markup {
|
||||
match guild_icon_url(
|
||||
config,
|
||||
guild_id,
|
||||
report.reported_guild_icon_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -158,6 +158,7 @@ pub fn instance_config_page(
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
instance_config.billing.enabled == Some(true),
|
||||
))
|
||||
},
|
||||
))
|
||||
@@ -199,7 +200,12 @@ pub fn instance_config_page(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
html! {
|
||||
(media_config_section(base, csrf_token, &instance_config.media))
|
||||
(media_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.media,
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
@@ -264,6 +270,7 @@ fn policy_config_section(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
@@ -274,7 +281,7 @@ fn policy_config_section(
|
||||
div class="space-y-8" {
|
||||
(single_community_form(base, csrf_token, policy))
|
||||
(direct_messages_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name, billing_enabled))
|
||||
(community_creation_form(base, csrf_token, policy))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
@@ -373,6 +380,7 @@ fn premium_mode_form(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
@@ -386,6 +394,16 @@ fn premium_mode_form(
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.as_str()),
|
||||
], policy.premium_mode.as_str()))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Limits you have not edited follow the model. Limits you changed under Limit \
|
||||
Config keep your values in both models."
|
||||
}
|
||||
@if billing_enabled && matches!(policy.premium_mode, PremiumMode::Mirror) {
|
||||
p class="text-sm text-amber-700" {
|
||||
"Billing is enabled. Disable it under Premium & billing before switching \
|
||||
the premium model to Everyone."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium model"))
|
||||
}))
|
||||
@@ -528,6 +546,16 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn smtp_tls_mode_summary(mode: &str) -> &str {
|
||||
match mode {
|
||||
"implicit" => "implicit TLS",
|
||||
"starttls" => "required STARTTLS",
|
||||
"opportunistic" => "STARTTLS when the server offers it",
|
||||
"none" => "no encryption",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
|
||||
let description = match account_identity.mode {
|
||||
AccountIdentityMode::Username => {
|
||||
@@ -671,15 +699,34 @@ fn integrations_config_section(
|
||||
))
|
||||
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
|
||||
}
|
||||
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
|
||||
(select_input("integration_smtp_tls_mode", "SMTP encryption", &[
|
||||
("", "Automatic"),
|
||||
("implicit", "Implicit TLS"),
|
||||
("starttls", "STARTTLS, required"),
|
||||
("opportunistic", "STARTTLS, when the server offers it"),
|
||||
("none", "None"),
|
||||
], integrations.email.smtp.tls_mode.as_deref().unwrap_or("")))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Automatic follows FLUXER_EMAIL_SMTP_TLS_MODE when it is set, then the older FLUXER_EMAIL_SMTP_SECURE, where true means implicit TLS on any port. With neither set it uses implicit TLS on port 465 and requires STARTTLS on every other port."
|
||||
}
|
||||
@if let Some(mode) = integrations.email.smtp.effective_tls_mode.as_deref() {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Outgoing email currently uses " (smtp_tls_mode_summary(mode))
|
||||
@if let Some(port) = integrations.email.smtp.port { " on port " (port) }
|
||||
"."
|
||||
}
|
||||
}
|
||||
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
|
||||
span { "Test SMTP connection" }
|
||||
span { "Save and test SMTP" }
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The test saves this form first, then connects with the saved settings, the same ones outgoing email uses."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +786,12 @@ fn integrations_config_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaResponse) -> Markup {
|
||||
fn media_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
media: &InstanceMediaResponse,
|
||||
self_hosted: bool,
|
||||
) -> Markup {
|
||||
let decay = &media.attachment_decay;
|
||||
let effective = &decay.effective;
|
||||
let enabled = decay.enabled.unwrap_or(effective.enabled);
|
||||
@@ -767,9 +819,14 @@ fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaRespo
|
||||
.renew_window_days
|
||||
.unwrap_or(effective.renew_window_days)
|
||||
.to_string();
|
||||
let description = if self_hosted {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. On a self-hosted instance, changes also apply to attachments uploaded earlier, which are kept for at least the renew window before a new or shorter expiry deletes them."
|
||||
} else {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. Changes apply to attachments uploaded from then on."
|
||||
};
|
||||
section_card_with_description(
|
||||
"Media Expiry",
|
||||
"Expire eligible attachments using size-based lifetimes. This is disabled by default and applies at runtime.",
|
||||
description,
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_media"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -893,9 +950,9 @@ fn app_public_config_section(
|
||||
))
|
||||
(text_input(
|
||||
"app_theme_color",
|
||||
"Theme Color",
|
||||
"Theme Color (hex)",
|
||||
app_public.branding.theme_color.as_deref().unwrap_or(""),
|
||||
"#5865f2",
|
||||
"#4641d9",
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
|
||||
@@ -8,7 +8,10 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, secondary_button,
|
||||
submit_button,
|
||||
},
|
||||
page_container::{card_with_header, page_header},
|
||||
tooltip,
|
||||
},
|
||||
@@ -25,6 +28,7 @@ const CATEGORY_ORDER: &[&str] = &[
|
||||
"expressions",
|
||||
"files",
|
||||
"social",
|
||||
"media",
|
||||
];
|
||||
|
||||
pub fn limit_config_page(
|
||||
@@ -81,7 +85,7 @@ fn render_limit_page(
|
||||
div class="space-y-6" {
|
||||
(rule_tabs(config, &rules, active_rule_id, can_update))
|
||||
@if let Some(rule) = active_rule {
|
||||
(rule_header(config, csrf_token, rule, can_update))
|
||||
(rule_header(&config.base_path, csrf_token, rule, can_update, response.defaults.contains_key(&rule.id)))
|
||||
(rule_editor(config, csrf_token, response, rule, can_update))
|
||||
} @else {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
||||
@@ -151,11 +155,18 @@ fn rule_tabs(
|
||||
}
|
||||
|
||||
fn rule_header(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
rule: &LimitRule,
|
||||
can_update: bool,
|
||||
has_defaults: bool,
|
||||
) -> Markup {
|
||||
let can_reset = can_update
|
||||
&& has_defaults
|
||||
&& rule
|
||||
.modified_fields
|
||||
.as_ref()
|
||||
.is_some_and(|fields| !fields.is_empty());
|
||||
card_with_header(
|
||||
"",
|
||||
html! {
|
||||
@@ -176,11 +187,20 @@ fn rule_header(
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
@if can_reset {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=reset&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(secondary_button("Reset to Defaults"))
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -112,9 +112,9 @@ fn render_user_detail(
|
||||
@if let Some(banner) = banner_url {
|
||||
div class="mb-4 space-y-2" {
|
||||
a href=(banner) target="_blank" rel="noreferrer noopener"
|
||||
class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
img src=(banner) alt={(user.username) "'s banner"}
|
||||
class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48"
|
||||
class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
|
||||
loading="lazy";
|
||||
}
|
||||
@if let Some(hash) = &user.banner {
|
||||
|
||||
@@ -6,7 +6,12 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> Markup {
|
||||
pub fn applications_tab(
|
||||
config: &AdminConfig,
|
||||
applications: &[Application],
|
||||
empty_title: &str,
|
||||
empty_body: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(card_with_header(
|
||||
@@ -15,10 +20,10 @@ pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> M
|
||||
@if applications.is_empty() {
|
||||
div class="flex flex-col items-center justify-center py-12 text-center" {
|
||||
p class="text-sm font-medium text-neutral-900" {
|
||||
"No applications owned"
|
||||
(empty_title)
|
||||
}
|
||||
p class="mt-1 text-sm text-neutral-500" {
|
||||
"This user does not own any applications."
|
||||
(empty_body)
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::{
|
||||
api::types::DmChannel, config::AdminConfig,
|
||||
templates::components::page_container::card_with_header,
|
||||
@@ -144,27 +146,14 @@ fn dm_pagination(
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
div class="flex items-center justify-center gap-2 rounded-lg border \
|
||||
border-neutral-200 bg-neutral-50 px-4 py-2" {
|
||||
@if has_previous {
|
||||
@if let Some(fid) = first_id {
|
||||
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_after=" (fid)}
|
||||
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
|
||||
transition-colors hover:bg-white hover:text-neutral-900" {
|
||||
"Previous"
|
||||
}
|
||||
}
|
||||
}
|
||||
@if has_next {
|
||||
@if let Some(lid) = last_id {
|
||||
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_before=" (lid)}
|
||||
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
|
||||
transition-colors hover:bg-white hover:text-neutral-900" {
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pager_with_missing(
|
||||
first_id.filter(|_| has_previous).map(|id| {
|
||||
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_after={id}")
|
||||
}),
|
||||
None,
|
||||
last_id.filter(|_| has_next).map(|id| {
|
||||
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_before={id}")
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::DELETION_REASONS;
|
||||
use crate::utils::plural::{count_noun, noun_for};
|
||||
use crate::{
|
||||
acl,
|
||||
@@ -30,31 +31,6 @@ const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
("0", "Permanent"),
|
||||
];
|
||||
|
||||
const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("1", "User requested"),
|
||||
("2", "Other"),
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
("10", "Privacy violation or doxxing"),
|
||||
("11", "Harassment or bullying"),
|
||||
("12", "Payment fraud"),
|
||||
("13", "Child safety violation"),
|
||||
("14", "Billing dispute or abuse"),
|
||||
("15", "Unsolicited explicit content"),
|
||||
("16", "Graphic violence"),
|
||||
("17", "Ban evasion"),
|
||||
("18", "Token or credential scam"),
|
||||
("19", "Inactivity"),
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
];
|
||||
|
||||
pub struct CurrentBan<'a> {
|
||||
pub entry: &'a AuditLogEntry,
|
||||
pub notes: Vec<&'a AuditLogEntry>,
|
||||
@@ -749,69 +725,3 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn user(extra: Value) -> AdminUser {
|
||||
let mut value =
|
||||
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
|
||||
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
|
||||
target.extend(fields.clone());
|
||||
}
|
||||
serde_json::from_value(value).expect("valid admin user")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_emails_the_user_by_default() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_ban_form_emails_the_user_by_default() {
|
||||
let markup =
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_offers_no_email_and_sends_none() {
|
||||
let pending = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3
|
||||
}));
|
||||
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let forms = [
|
||||
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
|
||||
];
|
||||
for markup in &forms {
|
||||
assert!(!markup.contains("Email the user"));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true""#));
|
||||
}
|
||||
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(!forms[1].contains("notify_user"));
|
||||
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unban_form_separates_the_public_and_private_reasons() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
|
||||
assert!(markup.contains("?action=unban&tab=moderation"));
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(markup.contains(
|
||||
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
|
||||
));
|
||||
assert!(markup.contains(r#"name="private_reason""#));
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,10 @@ fn traits_form(
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
|
||||
(custom_traits.join("\n"))
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"The premium trait follows each member's subscription or gift and cannot be \
|
||||
assigned here."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Traits"))
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::templates::components::report_category::{report_status, report_type};
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
@@ -119,47 +123,13 @@ fn report_section(
|
||||
}
|
||||
|
||||
@if has_previous || next_page.is_some() {
|
||||
div class="flex justify-center gap-2" {
|
||||
@if has_previous {
|
||||
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-2 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"Previous"
|
||||
}
|
||||
}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-2 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
(pager_with_missing(has_previous.then(|| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {current_page - 1} else {sent_page}, if kind == "received" {current_page - 1} else {received_page})), None, next_page.map(|page| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {page} else {sent_page}, if kind == "received" {page} else {received_page})), true))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tagged_user(
|
||||
global_name: Option<&str>,
|
||||
username: &str,
|
||||
@@ -252,7 +222,7 @@ fn type_and_category_lines(report: &ReportEntry) -> Markup {
|
||||
.zip(reason_label)
|
||||
.is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
html! {
|
||||
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
|
||||
div class="text-neutral-500 text-xs" { (report_type(report.report_type).0) }
|
||||
@if let Some(category) = &report.category {
|
||||
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
(report_category(category))
|
||||
@@ -319,7 +289,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
|
||||
(format_status(report.status))
|
||||
(report_status(report.status).0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"{
|
||||
@@ -500,7 +321,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"port": 587,
|
||||
"username": "fluxer",
|
||||
"password_set": true,
|
||||
"secure": true
|
||||
"tls_mode": null,
|
||||
"effective_tls_mode": "starttls"
|
||||
},
|
||||
"disable_new_ip_authorization": false,
|
||||
"effective_disable_new_ip_authorization": false
|
||||
@@ -713,70 +535,6 @@ fn serialize_push_relay_update_omits_an_unset_consent() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1508849882800552806",
|
||||
"reporter_id": "1474002886188635356",
|
||||
"reporter_tag": "user#2602",
|
||||
"reporter_username": "user",
|
||||
"reporter_global_name": null,
|
||||
"reporter_discriminator": "2602",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-05-26T15:10:45.134Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "nsfw_violation",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1461557793540882622",
|
||||
"reported_user_tag": "ReportedUser#4331",
|
||||
"reported_user_username": "ReportedUser",
|
||||
"reported_user_global_name": "ReportedUser",
|
||||
"reported_user_discriminator": "4331",
|
||||
"reported_user_avatar_hash": "75c675b2",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1508564782082866178",
|
||||
"reported_channel_id": "1487475053377430086",
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": []
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}"#;
|
||||
|
||||
let resp: types::SearchReportsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.total, 1);
|
||||
assert_eq!(resp.reports.len(), 1);
|
||||
assert_eq!(resp.reports[0].report_id, "1508849882800552806");
|
||||
assert_eq!(resp.reports[0].status, 0);
|
||||
assert_eq!(resp.reports[0].category.as_deref(), Some("nsfw_violation"));
|
||||
assert!(resp.reports[0].reported_guild_icon_hash.is_none());
|
||||
}
|
||||
|
||||
fn report_json(extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut report = serde_json::json!({
|
||||
"report_id": "1556008115701286089",
|
||||
@@ -1472,312 +1230,6 @@ fn deserialize_report_reason_list() {
|
||||
assert!(lenient.reasons[0].legacy_category_message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_lookup_guild_response() {
|
||||
let json = r#"{
|
||||
"guild": {
|
||||
"id": "123",
|
||||
"owner_id": "456",
|
||||
"owner_username": "admin",
|
||||
"owner_global_name": "Admin",
|
||||
"owner_discriminator": "0001",
|
||||
"name": "Test Guild",
|
||||
"vanity_url_code": null,
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"splash": null,
|
||||
"embed_splash": null,
|
||||
"features": [],
|
||||
"verification_level": 0,
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"explicit_content_filter": 0,
|
||||
"default_message_notifications": 0,
|
||||
"afk_channel_id": null,
|
||||
"afk_timeout": 300,
|
||||
"system_channel_id": null,
|
||||
"system_channel_flags": 0,
|
||||
"rules_channel_id": null,
|
||||
"disabled_operations": 0,
|
||||
"member_count": 100,
|
||||
"channels": [],
|
||||
"roles": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let resp: types::LookupGuildResponse = serde_json::from_str(json).unwrap();
|
||||
let guild = resp.guild.unwrap();
|
||||
assert_eq!(guild.id, "123");
|
||||
assert_eq!(guild.name, "Test Guild");
|
||||
assert_eq!(guild.member_count, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_lookup_guild_null() {
|
||||
let json = r#"{"guild": null}"#;
|
||||
let resp: types::LookupGuildResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.guild.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_lookup_application_response() {
|
||||
let json = r#"{
|
||||
"application": {
|
||||
"id": "111",
|
||||
"name": "My Bot",
|
||||
"owner_user_id": "222",
|
||||
"owner_username": "dev",
|
||||
"owner_global_name": null,
|
||||
"owner_discriminator": "0001",
|
||||
"bot_user_id": "333",
|
||||
"bot_username": "MyBot",
|
||||
"bot_global_name": null,
|
||||
"bot_discriminator": "0001",
|
||||
"bot_is_public": true,
|
||||
"bot_require_code_grant": false,
|
||||
"oauth2_redirect_uris": ["https://example.com/cb"],
|
||||
"has_client_secret": true,
|
||||
"has_bot_token": true,
|
||||
"bot_token_preview": "...xyz",
|
||||
"bot_token_created_at": "2025-01-01T00:00:00.000Z",
|
||||
"client_secret_created_at": "2025-01-01T00:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
}"#;
|
||||
|
||||
let resp: types::LookupApplicationResponse = serde_json::from_str(json).unwrap();
|
||||
let app = resp.application.unwrap();
|
||||
assert_eq!(app.id, "111");
|
||||
assert_eq!(app.name, "My Bot");
|
||||
assert!(app.bot_is_public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_refresh_search_index_response() {
|
||||
let json = r#"{"success": true, "job_id": "abc123"}"#;
|
||||
let resp: types::RefreshSearchIndexResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.job_id, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response() {
|
||||
let json = r#"{"banned": true}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response_with_expiry() {
|
||||
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_codes_response() {
|
||||
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
|
||||
let resp: types::CodesResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.codes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_user_mutation_response() {
|
||||
let json = r#"{
|
||||
"user": {
|
||||
"id": "1", "username": "updated", "discriminator": 42,
|
||||
"global_name": null, "bot": false, "system": false,
|
||||
"flags": "1", "premium_flags": 0, "avatar": null, "banner": null,
|
||||
"bio": null, "pronouns": null, "accent_color": null, "email": null,
|
||||
"email_verified": false, "email_bounced": false,
|
||||
"date_of_birth": null, "locale": null,
|
||||
"premium_type": null, "premium_since": null, "premium_until": null,
|
||||
"premium_grace_ends_at": null, "premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null, "pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null, "deletion_scheduled_at": null,
|
||||
"acls": [], "traits": [], "has_totp": false, "authenticator_types": [],
|
||||
"last_active_at": null, "last_active_ip": null,
|
||||
"last_active_ip_reverse": null, "last_active_location": null
|
||||
}
|
||||
}"#;
|
||||
let resp: types::UserMutationResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.user.username, "updated");
|
||||
assert_eq!(resp.user.discriminator, "0042");
|
||||
assert_eq!(resp.user.flags, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_guild_update_response() {
|
||||
let json = r#"{
|
||||
"guild": {
|
||||
"id": "123",
|
||||
"name": "Updated Guild",
|
||||
"features": ["VERIFIED"],
|
||||
"owner_id": "456",
|
||||
"owner_username": null,
|
||||
"owner_global_name": null,
|
||||
"owner_discriminator": null,
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"member_count": 50,
|
||||
"nsfw_level": 0
|
||||
}
|
||||
}"#;
|
||||
|
||||
let resp: types::GuildUpdateResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.guild.name, "Updated Guild");
|
||||
assert_eq!(resp.guild.member_count, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_list_guild_members_response() {
|
||||
let json = r#"{
|
||||
"members": [
|
||||
{
|
||||
"user": {
|
||||
"id": "111",
|
||||
"username": "member1",
|
||||
"discriminator": 1234,
|
||||
"global_name": "Member One",
|
||||
"avatar": null,
|
||||
"bot": false
|
||||
},
|
||||
"nick": "Memb",
|
||||
"joined_at": "2025-01-01T00:00:00.000Z",
|
||||
"roles": ["222", "333"]
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}"#;
|
||||
|
||||
let resp: types::ListGuildMembersResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.total, 1);
|
||||
assert_eq!(resp.members[0].user.username, "member1");
|
||||
assert_eq!(resp.members[0].user.discriminator, "1234");
|
||||
assert_eq!(resp.members[0].roles.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_list_user_sessions_response() {
|
||||
let json = r#"{
|
||||
"sessions": [
|
||||
{
|
||||
"session_id_hash": "abc123",
|
||||
"created_at": "2025-01-01T00:00:00.000Z",
|
||||
"approx_last_used_at": "2025-06-01T00:00:00.000Z",
|
||||
"client_ip": "1.2.3.4",
|
||||
"client_ip_reverse": "host.example.com",
|
||||
"client_os": "Windows 11",
|
||||
"client_platform": "desktop",
|
||||
"client_location": "New York, US"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let resp: types::ListUserSessionsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.sessions.len(), 1);
|
||||
assert_eq!(resp.sessions[0].client_ip, "1.2.3.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_webauthn_credentials_response() {
|
||||
let json = r#"[
|
||||
{
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
"name": "Touch ID",
|
||||
"created_at": "2026-05-25T12:00:00.000Z",
|
||||
"last_used_at": "2026-05-26T13:00:00.000Z"
|
||||
}
|
||||
]"#;
|
||||
|
||||
let credentials: types::WebAuthnCredentialListResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(credentials.len(), 2);
|
||||
assert_eq!(credentials[0].name, "YubiKey");
|
||||
assert!(credentials[0].last_used_at.is_none());
|
||||
assert_eq!(
|
||||
credentials[1].last_used_at.as_deref(),
|
||||
Some("2026-05-26T13:00:00.000Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_list_user_relationships_response() {
|
||||
let json = r#"{
|
||||
"friends": [
|
||||
{
|
||||
"target_user_id": "111",
|
||||
"category": "friend",
|
||||
"nickname": null,
|
||||
"since": "2025-01-01T00:00:00.000Z",
|
||||
"target": {
|
||||
"id": "111",
|
||||
"username": "friend1",
|
||||
"discriminator": "0042",
|
||||
"global_name": "Friend One",
|
||||
"avatar": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"incoming_requests": [],
|
||||
"outgoing_requests": [],
|
||||
"blocked": []
|
||||
}"#;
|
||||
|
||||
let resp: types::ListUserRelationshipsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.friends.len(), 1);
|
||||
assert_eq!(
|
||||
resp.friends[0].target.as_ref().unwrap().discriminator,
|
||||
"0042"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_create_admin_api_key_response() {
|
||||
let json = r#"{
|
||||
"key_id": "k_123",
|
||||
"key": "flx_secret_abc",
|
||||
"name": "My Key",
|
||||
"created_at": "2026-01-01T00:00:00.000Z",
|
||||
"expires_at": null,
|
||||
"acls": ["super_admin"]
|
||||
}"#;
|
||||
|
||||
let resp: types::CreateAdminApiKeyResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.key_id, "k_123");
|
||||
assert_eq!(resp.key, "flx_secret_abc");
|
||||
assert_eq!(resp.acls, vec!["super_admin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_list_admin_api_key_entry() {
|
||||
let json = r#"{
|
||||
"key_id": "k_123",
|
||||
"name": "My Key",
|
||||
"created_at": "2026-01-01T00:00:00.000Z",
|
||||
"last_used_at": "2026-05-01T00:00:00.000Z",
|
||||
"expires_at": null,
|
||||
"created_by_user_id": "1130650140672000000",
|
||||
"acls": ["super_admin", "reports"]
|
||||
}"#;
|
||||
|
||||
let resp: types::ListAdminApiKeyEntry = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.key_id, "k_123");
|
||||
assert_eq!(resp.created_by_user_id, "1130650140672000000");
|
||||
assert_eq!(resp.acls.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_lock_is_unknown_when_the_api_omits_it() {
|
||||
let identity: types::AccountIdentityConfigResponse =
|
||||
|
||||
@@ -10,7 +10,6 @@ use axum::{
|
||||
routing,
|
||||
};
|
||||
use fluxer_admin::{
|
||||
api::generated::types as generated_types,
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
@@ -21,7 +20,6 @@ use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "htmx-acceptance-test-secret";
|
||||
const ADMIN_API_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForAcceptance";
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
@@ -141,184 +139,6 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
|
||||
assert_eq!(received.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_legal_form_round_trips_the_guidelines_url() {
|
||||
let patches = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = patches.clone();
|
||||
let configured = || {
|
||||
let mut config = instance_config();
|
||||
config["self_hosted"] = json!(true);
|
||||
config["app_public"] = json!({
|
||||
"branding": {"product_name": "Fluxer", "premium_product_name": "Premium"},
|
||||
"setup": {"configured": true},
|
||||
"legal": {
|
||||
"terms_url": "https://example.com/terms",
|
||||
"privacy_url": null,
|
||||
"guidelines_url": "https://example.com/community-guidelines"
|
||||
},
|
||||
"registration": {"collect_date_of_birth": true}
|
||||
});
|
||||
config
|
||||
};
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/instance/config",
|
||||
routing::get(move || async move { json_response(configured()) }).patch(
|
||||
move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body);
|
||||
json_response(configured())
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await;
|
||||
assert!(
|
||||
page.contains(r#"id="app_guidelines_url" name="app_guidelines_url" value="https://example.com/community-guidelines""#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(page.contains("Community Guidelines URL"), "{page}");
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(
|
||||
&page,
|
||||
"/instance-config?action=update_app_legal",
|
||||
&csrf_token,
|
||||
);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
for form in [
|
||||
format!(
|
||||
"_csrf={csrf_token}&app_terms_url=https%3A%2F%2Fexample.com%2Fterms&app_privacy_url=&app_guidelines_url=https%3A%2F%2Frules.example.org%2Fguidelines"
|
||||
),
|
||||
format!("_csrf={csrf_token}&app_terms_url=&app_privacy_url=&app_guidelines_url="),
|
||||
] {
|
||||
let (status, _, body) = post_form_with_headers(
|
||||
&app,
|
||||
"/instance-config?action=update_app_legal",
|
||||
&[("Cookie", &cookie)],
|
||||
&form,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_redirection() || status.is_success(),
|
||||
"{status} {body}"
|
||||
);
|
||||
}
|
||||
let patches = patches.lock().unwrap();
|
||||
assert_eq!(
|
||||
*patches,
|
||||
vec![
|
||||
json!({"app_public": {"legal": {
|
||||
"terms_url": "https://example.com/terms",
|
||||
"privacy_url": null,
|
||||
"guidelines_url": "https://rules.example.org/guidelines"
|
||||
}}}),
|
||||
json!({"app_public": {"legal": {
|
||||
"terms_url": null,
|
||||
"privacy_url": null,
|
||||
"guidelines_url": null
|
||||
}}}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::patch(move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body);
|
||||
json_response(json!({
|
||||
"report_id": "1800000000000000001",
|
||||
"status": 1,
|
||||
"resolved_at": "2026-05-26T12:03:00.000Z",
|
||||
"public_comment": null
|
||||
}))
|
||||
}
|
||||
})
|
||||
.get(|| async { json_response(searched_report()) }),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(&page, "/reports/1800000000000000001/resolve", &csrf_token);
|
||||
assert!(
|
||||
page.contains(r#"<select id="resolution" name="resolution" required"#),
|
||||
"{page}"
|
||||
);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let post = |body: String| {
|
||||
let app = &app;
|
||||
let cookie = cookie.clone();
|
||||
async move {
|
||||
let (status, headers, text) = post_form_with_headers(
|
||||
app,
|
||||
"/reports/1800000000000000001/resolve",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "flash-container"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&body,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
|
||||
.to_owned()
|
||||
}
|
||||
};
|
||||
|
||||
for (value, label) in [
|
||||
("actioned", "Action taken"),
|
||||
("no_violation", "No violation"),
|
||||
("duplicate", "Duplicate"),
|
||||
] {
|
||||
let toast = post(format!("_csrf={csrf_token}&resolution={value}")).await;
|
||||
assert!(toast.contains("success"), "{toast}");
|
||||
assert!(
|
||||
toast.contains(&format!("Report resolved: {label}")),
|
||||
"{toast}"
|
||||
);
|
||||
}
|
||||
for refused in [
|
||||
format!("_csrf={csrf_token}&public_comment=Looks+fine"),
|
||||
format!("_csrf={csrf_token}&resolution=&public_comment=Looks+fine"),
|
||||
format!("_csrf={csrf_token}&resolution=auto_resolved"),
|
||||
] {
|
||||
let toast = post(refused).await;
|
||||
assert!(toast.contains("error"), "{toast}");
|
||||
assert!(toast.contains("Choose a resolution"), "{toast}");
|
||||
}
|
||||
let toast = post(format!(
|
||||
"_csrf={csrf_token}&resolution=actioned&public_comment=Removed¬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()));
|
||||
@@ -558,138 +378,14 @@ async fn spawn_mock_api(api: Router) -> String {
|
||||
}
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method == Method::PATCH && path == "/admin/instance/config" {
|
||||
return json_response(instance_config());
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
|
||||
(Method::GET, "/admin/api-keys") => json_response(json!([])),
|
||||
(Method::POST, "/admin/api-keys") => json_response(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
"key": ADMIN_API_KEY_SECRET,
|
||||
"name": "Acceptance key",
|
||||
"created_at": "2026-07-10T15:00:00.000Z",
|
||||
"expires_at": null,
|
||||
"acls": ["*"]
|
||||
})),
|
||||
(Method::GET, "/admin/users") => {
|
||||
json_response(json!({ "users": [searched_user()], "total": 1 }))
|
||||
}
|
||||
(Method::GET, "/admin/users/1500000000000000001") => {
|
||||
json_response(json!({ "users": [searched_user()] }))
|
||||
}
|
||||
(Method::POST, "/admin/users/1500000000000000001/password-reset") => {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
(Method::GET, "/admin/guilds") => {
|
||||
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
|
||||
}
|
||||
(Method::GET, "/admin/guilds/1600000000000000001") => {
|
||||
json_response(json!({ "guild": searched_guild_detail() }))
|
||||
}
|
||||
(Method::GET, "/admin/applications") => {
|
||||
json_response(json!({ "applications": [searched_application()] }))
|
||||
}
|
||||
(Method::GET, "/admin/reports")
|
||||
if query_value(&uri, "reason").as_deref() == Some("csam") =>
|
||||
{
|
||||
json_response(json!({
|
||||
"reports": [flow_report()],
|
||||
"total": 60,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}))
|
||||
}
|
||||
(Method::GET, "/admin/reports")
|
||||
if query_value(&uri, "category").as_deref() == Some("future_value") =>
|
||||
{
|
||||
json_response(json!({
|
||||
"reports": [future_category_report()],
|
||||
"total": 60,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}))
|
||||
}
|
||||
(Method::GET, "/admin/reports")
|
||||
if query_value(&uri, "reported_webhook_id").as_deref() == Some(WEBHOOK_ID) =>
|
||||
{
|
||||
json_response(json!({
|
||||
"reports": [webhook_report()],
|
||||
"total": 60,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}))
|
||||
}
|
||||
(Method::GET, "/admin/reports") => json_response(
|
||||
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
|
||||
),
|
||||
(Method::GET, "/admin/report-reasons") => json_response(report_reasons()),
|
||||
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
|
||||
(Method::GET, "/admin/reports/1800000000000000003") => json_response(flow_report()),
|
||||
(Method::GET, "/admin/reports/1800000000000000004") => json_response(webhook_report()),
|
||||
(Method::GET, "/admin/reports/1800000000000000005") => {
|
||||
json_response(future_status_report())
|
||||
}
|
||||
(Method::GET, "/admin/reports/1800000000000000002") => {
|
||||
json_response(searched_message_report())
|
||||
}
|
||||
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
|
||||
"report_id": "1800000000000000001",
|
||||
"status": 1,
|
||||
"resolved_at": "2026-05-26T12:03:00.000Z",
|
||||
"public_comment": "done"
|
||||
})),
|
||||
(Method::GET, "/admin/jobs") => {
|
||||
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
|
||||
}
|
||||
(Method::GET, "/admin/jobs/1900000000000000001") => {
|
||||
json_response(json!({ "job": searched_job() }))
|
||||
}
|
||||
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
|
||||
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
|
||||
"registration_url": registration_url_fixture(),
|
||||
"code": "11111111-1111-4111-8111-111111111111",
|
||||
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
|
||||
})),
|
||||
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
|
||||
json_response(instance_config_without_registration_urls())
|
||||
}
|
||||
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
|
||||
(Method::GET, "/admin/audit-logs") => {
|
||||
let access = uri.query().and_then(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(key, _)| key == "access")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
});
|
||||
let logs = [
|
||||
audit_log_entry("1900000000000000101", "get_user", "read"),
|
||||
audit_log_entry("1900000000000000102", "temp_ban", "write"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
access
|
||||
.as_deref()
|
||||
.is_none_or(|access| entry.access.to_string() == access)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json_response(json!({ "total": logs.len(), "logs": logs }))
|
||||
}
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_value(uri: &Uri, name: &str) -> Option<String> {
|
||||
uri.query().and_then(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn json_response(value: Value) -> Response {
|
||||
Json(value).into_response()
|
||||
}
|
||||
@@ -700,10 +396,6 @@ fn admin_user() -> Value {
|
||||
user
|
||||
}
|
||||
|
||||
fn searched_user() -> Value {
|
||||
user("1500000000000000001", "SearchedUser")
|
||||
}
|
||||
|
||||
fn user(id: &str, username: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
@@ -748,111 +440,6 @@ fn user(id: &str, username: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_guild() -> generated_types::GuildAdminResponse {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"name": "Searched Guild",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": "SearchedUser",
|
||||
"owner_global_name": "SearchedUser",
|
||||
"owner_discriminator": "0001",
|
||||
"member_count": 12,
|
||||
"features": ["COMMUNITY"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}))
|
||||
.expect("guild search fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": "SearchedUser",
|
||||
"owner_global_name": "SearchedUser",
|
||||
"owner_discriminator": "0001",
|
||||
"name": "Searched Guild",
|
||||
"vanity_url_code": null,
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"splash": null,
|
||||
"embed_splash": null,
|
||||
"features": ["COMMUNITY"],
|
||||
"verification_level": 1,
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 0,
|
||||
"default_message_notifications": 0,
|
||||
"afk_channel_id": null,
|
||||
"afk_timeout": 0,
|
||||
"system_channel_id": null,
|
||||
"system_channel_flags": 0,
|
||||
"rules_channel_id": null,
|
||||
"disabled_operations": 0,
|
||||
"member_count": 12,
|
||||
"channels": [],
|
||||
"roles": []
|
||||
}))
|
||||
.expect("guild detail fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn audit_log_entry(
|
||||
log_id: &str,
|
||||
action: &str,
|
||||
access: &str,
|
||||
) -> generated_types::AdminAuditLogResponseSchema {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1500000000000000000",
|
||||
"admin_user": null,
|
||||
"target_type": "user",
|
||||
"target_id": "1500000000000000001",
|
||||
"target_user": null,
|
||||
"target_guild": null,
|
||||
"target_channel": null,
|
||||
"related_users": {},
|
||||
"related_guilds": {},
|
||||
"related_channels": {},
|
||||
"action": action,
|
||||
"access": access,
|
||||
"audit_log_reason": null,
|
||||
"metadata": {},
|
||||
"created_at": "2026-09-16T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_application() -> Value {
|
||||
json!({
|
||||
"id": "1700000000000000001",
|
||||
"name": "Mock Application",
|
||||
"owner_user_id": "1500000000000000001",
|
||||
"owner_username": "SearchedUser",
|
||||
"owner_global_name": "SearchedUser",
|
||||
"owner_discriminator": "0001",
|
||||
"bot_user_id": null,
|
||||
"bot_username": null,
|
||||
"bot_global_name": null,
|
||||
"bot_discriminator": null,
|
||||
"bot_is_public": true,
|
||||
"bot_require_code_grant": false,
|
||||
"oauth2_redirect_uris": [],
|
||||
"has_client_secret": false,
|
||||
"has_bot_token": false,
|
||||
"bot_token_preview": null,
|
||||
"bot_token_created_at": null,
|
||||
"client_secret_created_at": null,
|
||||
"version": 1
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000001",
|
||||
@@ -868,21 +455,6 @@ fn searched_report() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn future_category_report() -> Value {
|
||||
let mut report = searched_report();
|
||||
report["report_id"] = json!("1800000000000000006");
|
||||
report["category"] = json!("future_value");
|
||||
report
|
||||
}
|
||||
|
||||
fn future_status_report() -> Value {
|
||||
let mut report = searched_report();
|
||||
report["report_id"] = json!("1800000000000000005");
|
||||
report["status"] = json!(7);
|
||||
report["report_type"] = json!(9);
|
||||
report
|
||||
}
|
||||
|
||||
fn report_reasons() -> Value {
|
||||
json!({
|
||||
"reasons": [
|
||||
@@ -906,323 +478,6 @@ fn report_reasons() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn flow_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000003",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_tag": "AdminUser#0001",
|
||||
"reported_at": "2026-05-26T12:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "child_safety",
|
||||
"additional_info": "Mock flow report details",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reported_user_tag": "SearchedUser#0001",
|
||||
"reported_message_id": "1800000000000001001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reason": "csam",
|
||||
"reason_label": "Child sexual abuse material",
|
||||
"reason_highest_priority": true,
|
||||
"reporter_good_faith_confirmed": true,
|
||||
"flow": {
|
||||
"revision_hash": "b7667e8b32c98c40",
|
||||
"surface": "dsa",
|
||||
"locale": "fr",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "private_info",
|
||||
"screen_title": "What private information is shared?",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": [
|
||||
{"id": "email", "label": "Email address"},
|
||||
{"id": "phone", "label": "Phone number"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_intro",
|
||||
"screen_title": "Report profile",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const WEBHOOK_ID: &str = "1700000000000000500";
|
||||
|
||||
fn webhook_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_tag": "AdminUser#0001",
|
||||
"reported_at": "2026-05-26T12:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_webhook_id": WEBHOOK_ID,
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": "abc123",
|
||||
"reported_webhook_default_name": "Harbor Hook",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1600000000000000101",
|
||||
"reported_webhook_guild_id": "1600000000000000001",
|
||||
"reported_webhook_created_at": "2026-05-20T08:30:00.000Z",
|
||||
"reported_webhook_creator_id": "1500000000000000002",
|
||||
"reported_webhook_creator_tag": "HookOwner#0002",
|
||||
"reported_webhook_creator_username": "HookOwner",
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": "0002",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_message_id": "1800000000000001004",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"reported_guild_id": "1600000000000000001",
|
||||
"reported_guild_name": "Searched Guild",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1800000000000001003",
|
||||
"content": "Member message before the webhook",
|
||||
"timestamp": "2026-05-26T12:00:30.000Z",
|
||||
"author_id": "1500000000000000001",
|
||||
"author_username": "SearchedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0001",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"channel_id": "1600000000000000101",
|
||||
"attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1800000000000001004",
|
||||
"content": "Webhook spam in drawer",
|
||||
"timestamp": "2026-05-26T12:01:00.000Z",
|
||||
"author_id": WEBHOOK_ID,
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": "abc123",
|
||||
"webhook_id": WEBHOOK_ID,
|
||||
"channel_id": "1600000000000000101",
|
||||
"attachments": []
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_message_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000002",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_tag": "AdminUser#0001",
|
||||
"reported_at": "2026-05-26T12:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": "Mock message report details",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reported_user_tag": "SearchedUser#0001",
|
||||
"reported_message_id": "1800000000000001001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"message_context": [{
|
||||
"id": "1800000000000001001",
|
||||
"content": "Reported message in drawer",
|
||||
"timestamp": "2026-05-26T12:01:00.000Z",
|
||||
"author_id": "1500000000000000001",
|
||||
"author_username": "SearchedUser",
|
||||
"author_global_name": "SearchedUser",
|
||||
"author_discriminator": "0001",
|
||||
"author_avatar": null,
|
||||
"channel_id": "1600000000000000101",
|
||||
"attachments": []
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_job() -> Value {
|
||||
json!({
|
||||
"job_id": "1900000000000000001",
|
||||
"task_type": "mockJobSync",
|
||||
"status": "running",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"progress_current": 4,
|
||||
"progress_total": 10,
|
||||
"progress_message": null,
|
||||
"error_message": null,
|
||||
"started_at": "2026-05-26T12:00:05.000Z",
|
||||
"completed_at": null,
|
||||
"attempts": 1,
|
||||
"max_attempts": 3,
|
||||
"requested_by_user_id": "1500000000000000000",
|
||||
"audit_log_reason": null,
|
||||
"jet_stream_lane": null,
|
||||
"jet_stream_seq": null,
|
||||
"run_at": null,
|
||||
"cancel_requested": false,
|
||||
"context_link": null,
|
||||
"payload": null,
|
||||
"result": null
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_config() -> Value {
|
||||
json!({
|
||||
"sso": {
|
||||
"enabled": false,
|
||||
"enforced": false,
|
||||
"display_name": null,
|
||||
"issuer": null,
|
||||
"authorization_url": null,
|
||||
"token_url": null,
|
||||
"userinfo_url": null,
|
||||
"jwks_url": null,
|
||||
"client_id": null,
|
||||
"client_secret_set": false,
|
||||
"scope": null,
|
||||
"allowed_domains": [],
|
||||
"auto_provision": false,
|
||||
"redirect_uri": "https://admin.example.test/oauth2_callback"
|
||||
},
|
||||
"gateway_rollout": {
|
||||
"session_rollout_percentage": 100,
|
||||
"session_rollout_mode": "modulo",
|
||||
"guild_rollout_percentage": 100,
|
||||
"rpc_request_timeout_ms": 5000,
|
||||
"max_concurrent_session_starts": 16,
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"voice_p2p": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_country_codes": [],
|
||||
"rollout_salt": "voice-p2p-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"max_participants": 2
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
},
|
||||
"registration": registration_config(),
|
||||
"self_hosted": false
|
||||
})
|
||||
}
|
||||
|
||||
fn registration_config() -> Value {
|
||||
json!({
|
||||
"mode": "approval",
|
||||
"admin_registration_urls_enabled": true,
|
||||
"urls": [registration_url_fixture()],
|
||||
"pending_registrations": [pending_registration_fixture()]
|
||||
})
|
||||
}
|
||||
|
||||
fn registration_url_fixture() -> Value {
|
||||
json!({
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"label": "Support batch",
|
||||
"created_by_user_id": "1500000000000000000",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"expires_at": null,
|
||||
"max_uses": 5,
|
||||
"use_count": 0,
|
||||
"revoked_at": null,
|
||||
"approval_required": true,
|
||||
"last_used_at": null,
|
||||
"last_used_by_user_id": null
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_registration_fixture() -> Value {
|
||||
json!({
|
||||
"user_id": "1500000000000000002",
|
||||
"username": "PendingUser",
|
||||
"discriminator": 0,
|
||||
"global_name": "Pending User",
|
||||
"email": "[email protected]",
|
||||
"requested_at": "2026-05-26T12:10:00.000Z",
|
||||
"registration_url_id": "11111111-1111-4111-8111-111111111111",
|
||||
"client_ip": "203.0.113.24"
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_config_without_pending_registrations() -> Value {
|
||||
let mut config = instance_config();
|
||||
config["registration"]["pending_registrations"] = json!([]);
|
||||
config
|
||||
}
|
||||
|
||||
fn instance_config_without_registration_urls() -> Value {
|
||||
let mut config = instance_config();
|
||||
config["registration"]["urls"] = json!([]);
|
||||
config
|
||||
}
|
||||
|
||||
fn limit_config() -> Value {
|
||||
json!({
|
||||
"limit_config": {
|
||||
"traitDefinitions": [],
|
||||
"rules": [{
|
||||
"id": "default",
|
||||
"filters": null,
|
||||
"limits": { "maxGuilds": 100 }
|
||||
}]
|
||||
},
|
||||
"limit_config_json": "{\"traitDefinitions\":[],\"rules\":[]}",
|
||||
"self_hosted": false,
|
||||
"defaults": {
|
||||
"default": { "maxGuilds": 100 }
|
||||
},
|
||||
"metadata": {
|
||||
"maxGuilds": {
|
||||
"key": "maxGuilds",
|
||||
"label": "Max Guilds",
|
||||
"description": "Maximum guild memberships.",
|
||||
"category": "account",
|
||||
"scope": "user",
|
||||
"isToggle": false,
|
||||
"unit": null,
|
||||
"min": 0,
|
||||
"max": 1000
|
||||
}
|
||||
},
|
||||
"categories": { "account": "Account" },
|
||||
"limit_keys": ["maxGuilds"],
|
||||
"bounds": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/i18n": "workspace:*",
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {createTransport} = vi.hoisted(() => ({createTransport: vi.fn()}));
|
||||
|
||||
vi.mock('nodemailer', () => ({default: {createTransport}}));
|
||||
|
||||
describe('SmtpEmailProvider', () => {
|
||||
beforeEach(() => {
|
||||
createTransport.mockReset();
|
||||
createTransport.mockReturnValue({sendMail: vi.fn(), verify: vi.fn()});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{port: 465, tlsMode: undefined, secure: true, requireTLS: false, ignoreTLS: false},
|
||||
{port: 587, tlsMode: undefined, secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 587, tlsMode: null, secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 587, tlsMode: 'implicit', secure: true, requireTLS: false, ignoreTLS: false},
|
||||
{port: 465, tlsMode: 'starttls', secure: false, requireTLS: true, ignoreTLS: false},
|
||||
{port: 25, tlsMode: 'opportunistic', secure: false, requireTLS: false, ignoreTLS: false},
|
||||
{port: 587, tlsMode: 'none', secure: false, requireTLS: false, ignoreTLS: true},
|
||||
] as const)('maps TLS mode $tlsMode on port $port to nodemailer options', ({port, tlsMode, ...expected}) => {
|
||||
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
|
||||
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, SmtpTlsModes} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
@@ -11,7 +12,7 @@ interface SmtpEmailConfig {
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
secure?: boolean;
|
||||
tlsMode?: SmtpTlsMode | null;
|
||||
connectionTimeoutMs?: number;
|
||||
greetingTimeoutMs?: number;
|
||||
socketTimeoutMs?: number;
|
||||
@@ -19,12 +20,20 @@ interface SmtpEmailConfig {
|
||||
|
||||
export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly transporter: Transporter;
|
||||
private readonly host: string;
|
||||
private readonly port: number;
|
||||
private readonly tlsMode: SmtpTlsMode;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure ?? true,
|
||||
secure: this.tlsMode === SmtpTlsModes.IMPLICIT,
|
||||
requireTLS: this.tlsMode === SmtpTlsModes.STARTTLS,
|
||||
ignoreTLS: this.tlsMode === SmtpTlsModes.NONE,
|
||||
auth: {
|
||||
user: config.username,
|
||||
pass: config.password,
|
||||
@@ -52,7 +61,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
logger.debug({to: message.to}, 'Email sent via SMTP');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({error}, 'SMTP send failed');
|
||||
logger.error({error, host: this.host, port: this.port, tlsMode: this.tlsMode}, 'SMTP send failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,28 +10,8 @@ export interface SentEmailRecord {
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) {
|
||||
return '*'.repeat(token.length);
|
||||
}
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
const noopLogger = {info: (_message: string) => {}};
|
||||
|
||||
export class TestEmailService implements IEmailService {
|
||||
private readonly sentEmails: Array<SentEmailRecord> = [];
|
||||
private readonly logger: {
|
||||
info: (message: string) => void;
|
||||
};
|
||||
|
||||
constructor(options?: {
|
||||
logger?: {
|
||||
info: (message: string) => void;
|
||||
};
|
||||
}) {
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
listSentEmails(): Array<SentEmailRecord> {
|
||||
return [...this.sentEmails];
|
||||
@@ -43,51 +23,41 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendPasswordResetEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
resetToken: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Password reset email sent to ${email} for user ${username}, token: ${maskToken(resetToken)}`);
|
||||
return this.record(email, 'password_reset', {token: resetToken});
|
||||
}
|
||||
|
||||
async sendEmailVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
verificationToken: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Email verification sent to ${email} for user ${username}, token: ${maskToken(verificationToken)}`,
|
||||
);
|
||||
return this.record(email, 'email_verification', {token: verificationToken});
|
||||
}
|
||||
|
||||
async sendIpAuthorizationEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
authorizationToken: string,
|
||||
ipAddress: string,
|
||||
location: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`IP authorization email sent to ${email} for user ${username}, IP: ${ipAddress}, location: ${location}`,
|
||||
);
|
||||
return this.record(email, 'ip_authorization', {token: authorizationToken, ip: ipAddress, location});
|
||||
}
|
||||
|
||||
async sendAccountTempBannedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
durationHours: number,
|
||||
bannedUntil: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Temp ban email sent to ${email} for user ${username}, ${durationHours}h until ${bannedUntil.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_temp_banned', {
|
||||
reason: reason ?? '',
|
||||
duration_hours: durationHours.toString(10),
|
||||
@@ -97,14 +67,11 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendAccountScheduledForDeletionEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Scheduled deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_scheduled_deletion', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
@@ -113,24 +80,20 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendSelfDeletionScheduledEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Self deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`);
|
||||
return this.record(email, 'self_deletion_scheduled', {deletion_date: deletionDate.toISOString()});
|
||||
}
|
||||
|
||||
async sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Requested deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_deletion_scheduled_requested', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
@@ -139,45 +102,37 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Inactivity deletion email sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'account_deletion_scheduled_inactivity', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountDeletionCancelledEmail(email: string, username: string, _locale?: string | null): Promise<boolean> {
|
||||
this.logger.info(`Deletion cancelled email sent to ${email} for user ${username}`);
|
||||
async sendAccountDeletionCancelledEmail(email: string, _username: string, _locale?: string | null): Promise<boolean> {
|
||||
return this.record(email, 'account_deletion_cancelled');
|
||||
}
|
||||
|
||||
async sendUnbanNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason ?? 'none'}`);
|
||||
return this.record(email, 'unban_notification', {reason: reason ?? ''});
|
||||
}
|
||||
|
||||
async sendScheduledDeletionNotification(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
deletionDate: Date,
|
||||
reason: string | null,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Scheduled deletion notification sent to ${email} for user ${username}, date: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'scheduled_deletion_notification', {
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
reason: reason ?? '',
|
||||
@@ -186,14 +141,11 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendInactivityWarningEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
deletionDate: Date,
|
||||
lastActiveDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Inactivity warning sent to ${email} for user ${username}, deletion: ${deletionDate.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'inactivity_warning', {
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
last_active_date: lastActiveDate.toISOString(),
|
||||
@@ -202,16 +154,13 @@ export class TestEmailService implements IEmailService {
|
||||
|
||||
async sendHarvestCompletedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
downloadUrl: string,
|
||||
totalMessages: number,
|
||||
fileSize: number,
|
||||
expiresAt: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Harvest completed email sent to ${email} for user ${username}, ${totalMessages} messages, ${fileSize} bytes`,
|
||||
);
|
||||
return this.record(email, 'harvest_completed', {
|
||||
download_url: downloadUrl,
|
||||
total_messages: totalMessages.toString(10),
|
||||
@@ -220,19 +169,17 @@ export class TestEmailService implements IEmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendGiftChargebackNotification(email: string, username: string, _locale?: string | null): Promise<boolean> {
|
||||
this.logger.info(`Gift chargeback notification sent to ${email} for user ${username}`);
|
||||
async sendGiftChargebackNotification(email: string, _username: string, _locale?: string | null): Promise<boolean> {
|
||||
return this.record(email, 'gift_chargeback_notification');
|
||||
}
|
||||
|
||||
async sendReportResolvedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Report resolved email sent to ${email} for user ${username}, report: ${reportId}`);
|
||||
return this.record(email, 'report_resolved', {report_id: reportId, public_comment: publicComment});
|
||||
}
|
||||
|
||||
@@ -242,7 +189,6 @@ export class TestEmailService implements IEmailService {
|
||||
publicComment: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`DSA report resolved email sent to ${email}, report: ${reportId}`);
|
||||
return this.record(email, 'dsa_report_resolved', {report_id: reportId, public_comment: publicComment});
|
||||
}
|
||||
|
||||
@@ -252,7 +198,6 @@ export class TestEmailService implements IEmailService {
|
||||
targetKind: ReportReceivedTargetKind,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Report received email sent to ${email}, report: ${reportId}`);
|
||||
return this.record(email, 'report_received', {report_id: reportId, target_kind: targetKind});
|
||||
}
|
||||
|
||||
@@ -262,53 +207,47 @@ export class TestEmailService implements IEmailService {
|
||||
expiresAt: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`DSA report verification code sent to ${email}, code: ${maskToken(code)}`);
|
||||
return this.record(email, 'dsa_report_verification', {code, expires_at: expiresAt.toISOString()});
|
||||
}
|
||||
|
||||
async sendPasswordChangeVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
code: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Password change verification sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'password_change_verification', {code});
|
||||
}
|
||||
|
||||
async sendMfaBackupCodesVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
code: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`MFA backup codes verification sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'mfa_backup_codes_view', {code});
|
||||
}
|
||||
|
||||
async sendEmailChangeOriginal(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
code: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Email change original verification sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'email_change_original', {code});
|
||||
}
|
||||
|
||||
async sendEmailChangeNew(email: string, username: string, code: string, _locale?: string | null): Promise<boolean> {
|
||||
this.logger.info(`Email change new verification sent to ${email} for user ${username}`);
|
||||
async sendEmailChangeNew(email: string, _username: string, code: string, _locale?: string | null): Promise<boolean> {
|
||||
return this.record(email, 'email_change_new', {code});
|
||||
}
|
||||
|
||||
async sendEmailChangeRevert(
|
||||
email: string,
|
||||
username: string,
|
||||
_username: string,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`Email change revert notice sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'email_change_revert', {token, new_email: newEmail});
|
||||
}
|
||||
|
||||
@@ -319,9 +258,6 @@ export class TestEmailService implements IEmailService {
|
||||
expiresAt: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Donation magic link sent to ${email}, token: ${maskToken(token)}, expires: ${expiresAt.toISOString()}`,
|
||||
);
|
||||
return this.record(email, 'donation_magic_link', {
|
||||
token,
|
||||
manage_url: manageUrl,
|
||||
@@ -337,9 +273,6 @@ export class TestEmailService implements IEmailService {
|
||||
manageUrl: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(
|
||||
`Donation confirmation sent to ${email}, amount: ${amountCents / 100} ${currency.toUpperCase()}/${interval}`,
|
||||
);
|
||||
return this.record(email, 'donation_confirmation', {
|
||||
amount: (amountCents / 100).toString(),
|
||||
currency: currency.toUpperCase(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {identityLocale} from '@fluxer/i18n/src/normalization/IdentityLocale';
|
||||
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
|
||||
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
|
||||
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
@@ -43,7 +42,6 @@ const emailI18n = createStaticI18n<EmailTemplateKey, EmailTemplate, Record<strin
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
defaultMessages: EMAIL_I18N_MESSAGES,
|
||||
localeMessages: EMAIL_I18N_LOCALE_MESSAGES,
|
||||
normalizeLocale: (locale) => identityLocale(locale),
|
||||
onWarning: (message) => {
|
||||
if (message.startsWith('Unsupported locale, falling back to en-US:')) {
|
||||
console.warn(
|
||||
|
||||
@@ -95,20 +95,6 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
|
||||
};
|
||||
|
||||
describe('EmailI18n locale files', () => {
|
||||
it.each(LOCALES)('%s loads without module errors', (locale) => {
|
||||
const template = getEmailTemplate(
|
||||
'email_verification',
|
||||
locale,
|
||||
{username: 'testuser', verifyUrl: 'https://example.com/verify'},
|
||||
'Fluxer',
|
||||
);
|
||||
expect(template.ok).toBe(true);
|
||||
});
|
||||
it.each(LOCALES)('%s has the same translation keys as the source catalog', (locale) => {
|
||||
const messagesKeys = Object.keys(EMAIL_I18N_MESSAGES).sort();
|
||||
const localeKeys = Object.keys(EMAIL_I18N_LOCALE_MESSAGES[locale]).sort();
|
||||
expect(localeKeys).toEqual(messagesKeys);
|
||||
});
|
||||
it.each(LOCALES)('%s keeps the source placeholders in every template', (locale) => {
|
||||
const messages: Partial<Record<EmailTemplateKey, {subject: string; body: string}>> =
|
||||
EMAIL_I18N_LOCALE_MESSAGES[locale];
|
||||
|
||||
Loaded 100 of 3079 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user