mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 21:48:29 +09:00
Compare commits
51
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 | ||
|
|
575a5adbef | ||
|
|
5695510980 | ||
|
|
c16d414d93 | ||
|
|
e7b13fd19f | ||
|
|
f22261f218 | ||
|
|
901ed623f6 | ||
|
|
1198b61648 | ||
|
|
d564ca5691 | ||
|
|
b7e92cbfe0 | ||
|
|
1cd62eb661 | ||
|
|
7e177a1c90 | ||
|
|
78471d8a85 | ||
|
|
6b6ae13919 | ||
|
|
7197371123 | ||
|
|
70fd9e6331 | ||
|
|
c14b7d4cb3 | ||
|
|
0f0ff5f3df | ||
|
|
b4c614d21a |
No files matched your search
@@ -28,7 +28,6 @@ services:
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -86,11 +85,6 @@ services:
|
||||
target: /workspaces/fluxer/packages/hono/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: package-hono-types-node-modules
|
||||
target: /workspaces/fluxer/packages/hono_types/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: package-i18n-node-modules
|
||||
target: /workspaces/fluxer/packages/i18n/node_modules
|
||||
@@ -166,21 +160,11 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/http_client/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-initialization-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/initialization/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-kv-client-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/kv_client/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-locale-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/locale/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-media-proxy-utils-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/media_proxy_utils/node_modules
|
||||
@@ -355,7 +339,6 @@ volumes:
|
||||
package-errors-node-modules:
|
||||
package-geo-utils-node-modules:
|
||||
package-hono-node-modules:
|
||||
package-hono-types-node-modules:
|
||||
package-i18n-node-modules:
|
||||
package-instance-bootstrap-node-modules:
|
||||
package-ip-utils-node-modules:
|
||||
@@ -371,9 +354,7 @@ volumes:
|
||||
fluxer-api-email-node-modules:
|
||||
fluxer-api-geoip-node-modules:
|
||||
fluxer-api-http-client-node-modules:
|
||||
fluxer-api-initialization-node-modules:
|
||||
fluxer-api-kv-client-node-modules:
|
||||
fluxer-api-locale-node-modules:
|
||||
fluxer-api-media-proxy-utils-node-modules:
|
||||
fluxer-api-mime-utils-node-modules:
|
||||
fluxer-api-nats-node-modules:
|
||||
|
||||
+3
-3
@@ -38,6 +38,9 @@
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
/fluxer_app/src/features/ui/components/SVGMasks.tsx
|
||||
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
|
||||
/fluxer_static/desktop/spellcheck/dictionaries/*/*
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
/fluxer_gateway/priv/
|
||||
|
||||
/fluxer_media_proxy/fuzz/artifacts/
|
||||
@@ -45,9 +48,6 @@
|
||||
/packages/markdown_parser/rust/fuzz/artifacts/
|
||||
/packages/markdown_parser/rust/fuzz/corpus/
|
||||
|
||||
/fluxer_media_proxy/.benchmark-cache/
|
||||
/fluxer_media_proxy/bench-results/
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
* text=auto
|
||||
fluxer_static/** -text -diff
|
||||
fluxer_static/**/*.md text diff
|
||||
fluxer_static/*.sh text eol=lf diff
|
||||
fluxer_static/*.sha256 text eol=lf diff
|
||||
packages/fonts/files/** -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.wasm -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.tar.gz -text -diff
|
||||
fluxer_app/src/features/i18n/locales/** linguist-generated
|
||||
**/weblate/locales/** linguist-generated
|
||||
fluxer_api/src/api/openapi/openapi.json linguist-generated
|
||||
fluxer_admin/openapi-admin.json linguist-generated
|
||||
@@ -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 }}
|
||||
@@ -67,9 +67,6 @@ p:geo-utils:
|
||||
p:hono:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/hono/**/*
|
||||
p:hono-types:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/hono_types/**/*
|
||||
p:i18n:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/i18n/**/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -740,7 +740,6 @@ jobs:
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
--step install_dependencies
|
||||
|
||||
- name: Compile translated catalogs
|
||||
run: pnpm i18n:weblate-compile
|
||||
run: pnpm i18n:compile
|
||||
|
||||
- name: Format generated catalogs
|
||||
run: pnpm biome format --write package.json packages/i18n/package.json packages/i18n/scripts/SyncStaticI18nCatalogs.ts packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
|
||||
+25
-122
@@ -15,7 +15,6 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
CARGO_PROFILE_DEV_DEBUG: none
|
||||
CARGO_PROFILE_TEST_DEBUG: none
|
||||
CARGO_INCREMENTAL: '0'
|
||||
@@ -36,27 +35,10 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
|
||||
- name: Save ci helper
|
||||
if: github.ref == 'refs/heads/main' && steps.ci-helper.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
save: 'true'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -91,18 +73,8 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -119,22 +91,9 @@ jobs:
|
||||
|
||||
- name: Restore fluxer_app wasm artifacts
|
||||
id: app-wasm
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: restore
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -142,22 +101,9 @@ jobs:
|
||||
|
||||
- name: Save fluxer_app wasm artifacts
|
||||
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: save
|
||||
|
||||
rust:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -277,6 +223,9 @@ jobs:
|
||||
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: Run tests (libfluxcore)
|
||||
run: cargo test --locked --manifest-path fluxer_app/rust/libfluxcore/Cargo.toml
|
||||
|
||||
- name: Run desktop native workspace tests on Linux
|
||||
run: tools/ci/check-desktop-native-workspaces.sh test
|
||||
|
||||
@@ -302,18 +251,8 @@ jobs:
|
||||
fluxer_gateway/native/push_markdown_plaintext_nif -> target
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Set up Erlang
|
||||
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
|
||||
@@ -383,18 +322,8 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -411,22 +340,9 @@ jobs:
|
||||
|
||||
- name: Restore fluxer_app wasm artifacts
|
||||
id: app-wasm
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: restore
|
||||
|
||||
- name: Run knip
|
||||
run: |
|
||||
@@ -434,22 +350,9 @@ jobs:
|
||||
|
||||
- name: Save fluxer_app wasm artifacts
|
||||
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: save
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -501,14 +404,14 @@ jobs:
|
||||
- name: Compile locale catalogs
|
||||
run: pnpm i18n:compile
|
||||
|
||||
- name: Check drift of compiled locale modules
|
||||
- name: Check drift of synced catalogs
|
||||
run: |
|
||||
if ! git diff --exit-code -- \
|
||||
packages/errors/src/i18n/locales \
|
||||
fluxer_api/pkgs/email/src/email_i18n/locales \
|
||||
packages/errors/src/i18n/weblate \
|
||||
fluxer_api/pkgs/email/src/email_i18n/weblate \
|
||||
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
|
||||
fluxer_api/src/api/content_i18n/locales; then
|
||||
echo "::error::Compiled locale modules are outdated. Run 'pnpm i18n:compile' and commit the result. Translations belong in the weblate/ catalogs, not in the generated locales/ modules."
|
||||
fluxer_api/src/api/content_i18n/weblate; then
|
||||
echo "::error::Static catalogs are out of sync with their sources. Run 'pnpm i18n:compile' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+3
-5
@@ -35,15 +35,13 @@
|
||||
/fluxer_app/src/features/ui/components/SVGMasks.tsx
|
||||
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
|
||||
/fluxer_gateway/priv/
|
||||
/fluxer_static/desktop/spellcheck/dictionaries/*/*
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
|
||||
/fluxer_desktop/native/rust/fuzz/artifacts/
|
||||
/fluxer_desktop/native/rust/fuzz/corpus/
|
||||
/packages/markdown_parser/rust/fuzz/artifacts/
|
||||
/packages/markdown_parser/rust/fuzz/corpus/
|
||||
|
||||
/fluxer_media_proxy/.benchmark-cache/
|
||||
/fluxer_media_proxy/bench-results/
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
|
||||
Generated
+18
-89
@@ -1157,8 +1157,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1185,16 +1183,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
@@ -1736,8 +1724,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
@@ -1781,7 +1770,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1794,7 +1782,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"flate2",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
@@ -1805,6 +1792,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -1836,7 +1824,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
@@ -1861,6 +1848,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"clap",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"p256",
|
||||
@@ -1901,6 +1889,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"libc",
|
||||
"rmp-serde",
|
||||
@@ -2017,24 +2006,21 @@ name = "fluxer_common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"http 1.5.0",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2637,12 +2623,6 @@ version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2891,18 +2871,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"memchr",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
@@ -2919,6 +2887,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3331,34 +3308,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -3728,26 +3677,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
||||
@@ -163,7 +163,6 @@
|
||||
"**",
|
||||
"!**/.git",
|
||||
"!**/app.css",
|
||||
"!fluxer_admin/public/static/app.css",
|
||||
"!**/build",
|
||||
"fluxer_app/scripts/build",
|
||||
"!**/dist",
|
||||
@@ -184,11 +183,8 @@
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/locales",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
|
||||
"!fluxer_api/src/api/content_i18n/locales",
|
||||
"!fluxer_api/src/api/content_i18n/weblate",
|
||||
"!packages/errors/src/i18n/locales",
|
||||
"!packages/errors/src/i18n/weblate",
|
||||
"!**/auto-i18n-reviewed-unchanged.json"
|
||||
],
|
||||
|
||||
Vendored
+1
-3
@@ -104,7 +104,7 @@ FLUXER_EMAIL_SMTP_HOST=mailpit
|
||||
FLUXER_EMAIL_SMTP_PORT=1025
|
||||
FLUXER_EMAIL_SMTP_USERNAME=dev
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=dev
|
||||
FLUXER_EMAIL_SMTP_SECURE=false
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=none
|
||||
FLUXER_SEARCH_ENGINE=meilisearch
|
||||
FLUXER_SEARCH_URL=http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
|
||||
@@ -120,8 +120,6 @@ FLUXER_TEST_MODE_ENABLED=false
|
||||
|
||||
PUBLIC_BUILD_VERSION=dev
|
||||
PUBLIC_RELEASE_CHANNEL=canary
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
|
||||
AWS_EC2_METADATA_DISABLED=true
|
||||
@@ -56,7 +56,6 @@ workloads:
|
||||
name: fluxer-admin
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
@@ -71,10 +70,6 @@ workloads:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
|
||||
@@ -318,7 +318,13 @@ FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=
|
||||
FLUXER_EMAIL_SMTP_SECURE=true
|
||||
# How the SMTP connection is encrypted: implicit, starttls, opportunistic or
|
||||
# none. Leave it empty for implicit TLS on port 465 and required STARTTLS on any
|
||||
# other port.
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
@@ -377,7 +383,6 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_INVITE_ENDPOINT=
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
|
||||
@@ -121,6 +121,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
@@ -656,25 +657,19 @@ services:
|
||||
environment:
|
||||
RUST_LOG: ${RUST_LOG:-}
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
|
||||
Generated
+69
-19
@@ -3074,7 +3074,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
|
||||
"description": "Permanently deletes a guild. The guild becomes unavailable at once, and a background job deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4144,10 +4144,10 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"description": "Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}}
|
||||
}
|
||||
}
|
||||
@@ -11107,7 +11107,7 @@
|
||||
"category": {"type": "string"},
|
||||
"scope": {"type": "string"},
|
||||
"isToggle": {"type": "boolean"},
|
||||
"unit": {"type": "string", "enum": ["bytes", "count"]},
|
||||
"unit": {"type": "string", "enum": ["bytes", "count", "bps"]},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"}
|
||||
},
|
||||
@@ -11154,6 +11154,7 @@
|
||||
"max_private_channels_per_user",
|
||||
"max_reactions_per_message",
|
||||
"max_relationships",
|
||||
"max_screen_share_bitrate",
|
||||
"max_users_per_message_reaction",
|
||||
"max_voice_message_duration",
|
||||
"max_webhooks_per_channel",
|
||||
@@ -11523,9 +11524,34 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string"},
|
||||
"password_set": {"type": "boolean"},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "Saved TLS mode, or null to pick one from the port",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"effective_tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "username", "password_set", "secure"],
|
||||
"required": ["host", "port", "username", "password_set", "tls_mode", "effective_tls_mode"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"disable_new_ip_authorization": {"type": "boolean"},
|
||||
@@ -11788,13 +11814,24 @@
|
||||
"InstanceEmailSmtpTestRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"secure": {"default": true, "type": "boolean"}
|
||||
},
|
||||
"required": ["host", "port", "username", "password"]
|
||||
"host": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 255},
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"InstanceEmailSmtpTestResponse": {
|
||||
"type": "object",
|
||||
@@ -11869,7 +11906,11 @@
|
||||
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
|
||||
"theme_color": {
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"pattern": "^(?:#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$"
|
||||
},
|
||||
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
|
||||
@@ -11923,7 +11964,20 @@
|
||||
"port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"username": {"nullable": true, "type": "string", "maxLength": 320},
|
||||
"password": {"nullable": true, "type": "string", "maxLength": 4096},
|
||||
"secure": {"nullable": true, "type": "boolean"}
|
||||
"tls_mode": {
|
||||
"nullable": true,
|
||||
"description": "How the SMTP connection is encrypted",
|
||||
"x-enumNames": ["IMPLICIT", "STARTTLS", "OPPORTUNISTIC", "NONE"],
|
||||
"x-enumDescriptions": [
|
||||
"TLS from the first byte, usually on port 465",
|
||||
"Plain connection that must upgrade with STARTTLS, usually on port 587",
|
||||
"Plain connection upgraded with STARTTLS when the server offers it",
|
||||
"No TLS, even when the server offers STARTTLS"
|
||||
],
|
||||
"enum": ["implicit", "starttls", "opportunistic", "none"],
|
||||
"type": "string"
|
||||
},
|
||||
"secure": {"description": "Deprecated, use tls_mode", "nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"disable_new_ip_authorization": {"nullable": true, "type": "boolean"}
|
||||
@@ -17218,7 +17272,6 @@
|
||||
"ACCOUNT_TOO_NEW_FOR_GUILD",
|
||||
"ACLS_MUST_BE_NON_EMPTY",
|
||||
"ADMIN_API_KEY_NOT_FOUND",
|
||||
"APPLICATION_NOT_OWNED",
|
||||
"ALREADY_FRIENDS",
|
||||
"BOTS_CANNOT_CREATE_GUILDS",
|
||||
"BOTS_CANNOT_SEND_FRIEND_REQUESTS",
|
||||
@@ -17244,7 +17297,6 @@
|
||||
"CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL",
|
||||
"CANNOT_SEND_MESSAGES_TO_USER",
|
||||
"CANNOT_TRANSFER_OWNERSHIP_TO_BOT",
|
||||
"CANNOT_SHRINK_RESERVED_SLOTS",
|
||||
"CAPTCHA_REQUIRED",
|
||||
"CHANNEL_ALREADY_FOLLOWED",
|
||||
"CHANNEL_HAS_FOLLOWED_CHANNELS",
|
||||
@@ -17515,7 +17567,6 @@
|
||||
"Your account is too new to send messages in this community.",
|
||||
"ACLs must be non-empty.",
|
||||
"Admin API key wasn't found.",
|
||||
"You don't own this application.",
|
||||
"You're already friends with this user.",
|
||||
"Bots can't create communities.",
|
||||
"Bots can't send friend requests.",
|
||||
@@ -17541,7 +17592,6 @@
|
||||
"You can't send messages in a non-text channel.",
|
||||
"You can't send messages to this user.",
|
||||
"Community ownership can't be transferred to a bot.",
|
||||
"You can't shrink reserved slots.",
|
||||
"Verification required. Try again.",
|
||||
"This channel already receives updates from that announcement channel.",
|
||||
"Remove the followed channels posting here before converting it to an announcement channel.",
|
||||
|
||||
@@ -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,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
use super::types::{BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -205,9 +207,10 @@ impl AdminApiClient {
|
||||
let body = generated_types::BulkBanFileShasRequest {
|
||||
sha256_list: sha256_list.to_vec(),
|
||||
};
|
||||
self.put_typed_with_reason(
|
||||
self.send(
|
||||
Method::PUT,
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
&body,
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -249,16 +252,6 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_profile_substring(
|
||||
&self,
|
||||
scope: &str,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{guild_features, user_flags};
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -18,8 +22,13 @@ impl AdminApiClient {
|
||||
remove_flags: user_flags(remove_flags)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -34,8 +43,13 @@ impl AdminApiClient {
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_add_guild_members(
|
||||
@@ -48,8 +62,13 @@ impl AdminApiClient {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
@@ -60,8 +79,13 @@ impl AdminApiClient {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
@@ -89,29 +113,16 @@ impl AdminApiClient {
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::GuildFeatureSchema::from)
|
||||
.collect()
|
||||
}
|
||||
+17
-146
@@ -49,6 +49,15 @@ impl AdminApiClient {
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self {
|
||||
let generated = GeneratedClient::new_with_client(
|
||||
&config.api_endpoint,
|
||||
http_client.clone(),
|
||||
build_proxy_client_ip_headers(config),
|
||||
);
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||||
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||||
let query = query_params
|
||||
@@ -127,170 +136,32 @@ impl AdminApiClient {
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
pub async fn send<T, B>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.post_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed<T, B>(&self, path: &str, body: &B) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
self.post_typed_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
pub async fn send_void<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.post_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.patch_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
@@ -284,11 +279,6 @@ pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BanAvatarResult {
|
||||
pub hash_short: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -78,8 +78,3 @@ pub struct CreateVoiceServerResponse {
|
||||
pub struct UpdateVoiceServerResponse {
|
||||
pub server: VoiceServer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GetVoiceServerResponse {
|
||||
pub server: Option<VoiceServer>,
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{bool_param, user_flags};
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -41,16 +45,6 @@ impl AdminApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_users(None, None, None, None, None, Some(query), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.users.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn lookup_users_by_ids(&self, user_ids: &[String]) -> ApiResult<Vec<AdminUser>> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
@@ -377,9 +371,10 @@ impl AdminApiClient {
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.put_typed_with_reason(
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
&body,
|
||||
Some(&body),
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
@@ -449,7 +444,8 @@ impl AdminApiClient {
|
||||
"notify_user": notify_user,
|
||||
});
|
||||
let resp: UserMutationResponse = self
|
||||
.delete_with_reason(
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
@@ -468,9 +464,11 @@ impl AdminApiClient {
|
||||
"ban_audit_log_id": ban_audit_log_id,
|
||||
"note": note,
|
||||
});
|
||||
self.post_void(
|
||||
self.send_void(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -599,20 +597,6 @@ fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn premium_flags(values: &[i32]) -> Vec<generated_types::PremiumFlags> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::bool_param;
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
CreateVoiceRegionResponse, CreateVoiceServerResponse, DeleteVoiceResponse,
|
||||
GetVoiceRegionResponse, GetVoiceServerResponse, ListVoiceRegionsResponse,
|
||||
ListVoiceServersResponse, UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
GetVoiceRegionResponse, ListVoiceRegionsResponse, ListVoiceServersResponse,
|
||||
UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -57,7 +61,8 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body = voice_request_body(params, &["id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
@@ -83,19 +88,6 @@ impl AdminApiClient {
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_voice_server(
|
||||
&self,
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<GetVoiceServerResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn create_voice_server(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
@@ -123,7 +115,8 @@ impl AdminApiClient {
|
||||
paired_coordinates(params)?;
|
||||
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
@@ -149,10 +142,6 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
|
||||
serde_json::from_value::<T>(params.clone())
|
||||
.map(drop)
|
||||
|
||||
@@ -10,7 +10,6 @@ const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrob
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminConfig {
|
||||
pub env: RuntimeEnv,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub secret_key_base: String,
|
||||
@@ -35,13 +34,6 @@ pub struct ProxyConfig {
|
||||
pub client_ip_header_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimeEnv {
|
||||
Development,
|
||||
Production,
|
||||
Test,
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
|
||||
@@ -60,7 +52,6 @@ impl AdminConfig {
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
|
||||
port: read_env("FLUXER_ADMIN_PORT", "3020")
|
||||
.parse()
|
||||
@@ -105,14 +96,6 @@ impl AdminConfig {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_dev(&self) -> bool {
|
||||
self.env == RuntimeEnv::Development
|
||||
}
|
||||
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
@@ -189,16 +172,6 @@ fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
|
||||
&& !bucket.ends_with('-')
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
pub(crate) fn from_env_value(value: &str) -> Self {
|
||||
match value {
|
||||
"production" => Self::Production,
|
||||
"test" => Self::Test,
|
||||
_ => Self::Development,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,8 +180,7 @@ mod tests {
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 15] = [
|
||||
"FLUXER_ENV",
|
||||
const MANAGED_ENV: [&str; 14] = [
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
@@ -243,135 +215,6 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_strips_trailing_slashes() {
|
||||
assert_eq!(normalize_base_path("admin/"), "/admin");
|
||||
assert_eq!(normalize_base_path("admin///"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_adds_leading_slash() {
|
||||
assert_eq!(normalize_base_path("admin"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_empty_stays_empty() {
|
||||
assert_eq!(normalize_base_path(""), "");
|
||||
assert_eq!(normalize_base_path(" "), "");
|
||||
assert_eq!(normalize_base_path("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_preserves_inner() {
|
||||
assert_eq!(normalize_base_path("/foo/bar/"), "/foo/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_removes_trailing() {
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com/"),
|
||||
"https://example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com"),
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_empty_string() {
|
||||
assert_eq!(trim_trailing_slash(""), "");
|
||||
assert_eq!(trim_trailing_slash("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_from_env_value() {
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("production"),
|
||||
RuntimeEnv::Production
|
||||
);
|
||||
assert_eq!(RuntimeEnv::from_env_value("test"), RuntimeEnv::Test);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("development"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("anything"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_production_returns_true_for_production() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_production());
|
||||
assert!(!config.is_dev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dev_returns_true_for_development() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Development,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_dev());
|
||||
assert!(!config.is_production());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_uses_defaults() {
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.env, RuntimeEnv::Development);
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3020);
|
||||
assert_eq!(config.oauth_client_id, DEFAULT_ADMIN_OAUTH_CLIENT_ID);
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
"https://admin.fluxer.app/oauth2_callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_public_endpoints() {
|
||||
let config = config_from_env(&[
|
||||
|
||||
@@ -13,43 +13,6 @@ pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stylesheet_is_served_and_content_hashed() {
|
||||
let (content_type, bytes) =
|
||||
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
|
||||
assert_eq!(content_type, "text/css; charset=utf-8");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
assert!(css.contains("font-family: 'Fluxer Sans'"));
|
||||
assert!(css.contains("font-family: 'Fluxer Mono'"));
|
||||
assert!(
|
||||
!css.contains("?v="),
|
||||
"content hashing replaces cache-bust tokens"
|
||||
);
|
||||
assert!(
|
||||
!css.contains("fluxerstatic"),
|
||||
"fonts must not be fetched from the static CDN"
|
||||
);
|
||||
assert!(
|
||||
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
|
||||
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_face_the_stylesheet_references_is_served() {
|
||||
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
let mut referenced = 0;
|
||||
for fragment in css.split("url('").skip(1) {
|
||||
let file_name = fragment.split('\'').next().expect("unterminated url()");
|
||||
let (content_type, _) = asset(file_name)
|
||||
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
|
||||
assert_eq!(content_type, "font/woff2");
|
||||
referenced += 1;
|
||||
}
|
||||
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ofl_attribution_ships_with_the_binaries() {
|
||||
let notice = ASSETS
|
||||
@@ -63,10 +26,4 @@ mod tests {
|
||||
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_files_are_not_served() {
|
||||
assert!(asset("fonts.css").is_none());
|
||||
assert!(asset("../../../etc/passwd").is_none());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -198,14 +198,13 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::config::{AdminConfig, ProxyConfig};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
|
||||
@@ -108,66 +108,3 @@ pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool
|
||||
response.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize_then_parse_round_trips() {
|
||||
let flash = FlashData::success("Item saved");
|
||||
let encoded = serialize_flash(&flash);
|
||||
let decoded = parse_flash(&encoded).expect("must parse");
|
||||
assert_eq!(decoded.message, "Item saved");
|
||||
assert_eq!(decoded.flash_type, "success");
|
||||
assert!(decoded.detail.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_base64() {
|
||||
assert!(parse_flash("!!!not-base64!!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_json() {
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json");
|
||||
assert!(parse_flash(&encoded).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_success_sets_correct_type() {
|
||||
let f = FlashData::success("ok");
|
||||
assert_eq!(f.flash_type, "success");
|
||||
assert_eq!(f.message, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_error_sets_correct_type() {
|
||||
let f = FlashData::error("fail");
|
||||
assert_eq!(f.flash_type, "error");
|
||||
assert_eq!(f.message, "fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_info_sets_correct_type() {
|
||||
let f = FlashData::info("note");
|
||||
assert_eq!(f.flash_type, "info");
|
||||
assert_eq!(f.message, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_flash_message_maps_levels() {
|
||||
assert!(matches!(
|
||||
FlashData::success("").to_flash_message().level,
|
||||
FlashLevel::Success
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::error("").to_flash_message().level,
|
||||
FlashLevel::Error
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::info("").to_flash_message().level,
|
||||
FlashLevel::Info
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,181 +127,3 @@ fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_message_and_attachment_fields() {
|
||||
let value = json!({
|
||||
"id": "9007199254740993",
|
||||
"content": "Message content",
|
||||
"timestamp": "2026-09-11T12:00:00Z",
|
||||
"author_id": 42,
|
||||
"author_username": "alice",
|
||||
"author_global_name": "Alice",
|
||||
"author_discriminator": 1234,
|
||||
"author_avatar": "avatar-hash",
|
||||
"channel_id": "100",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": 2,
|
||||
"channel_content_warning_text": "Content warning",
|
||||
"guild_nsfw": true,
|
||||
"attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"url": "https://cdn.example.com/image.png",
|
||||
"filename": "image.png",
|
||||
"nsfw": true,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
message_from_value(&value),
|
||||
Message {
|
||||
id: "9007199254740993".into(),
|
||||
content: "Message content".into(),
|
||||
timestamp: "2026-09-11T12:00:00Z".into(),
|
||||
author_id: "42".into(),
|
||||
author_username: "alice".into(),
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
channel_content_warning_text: Some("Content warning".into()),
|
||||
guild_nsfw: Some(true),
|
||||
attachments: vec![Attachment {
|
||||
id: "9007199254740993".into(),
|
||||
url: "https://cdn.example.com/image.png".into(),
|
||||
filename: "image.png".into(),
|
||||
nsfw: Some(true),
|
||||
content_type: Some("image/png".into()),
|
||||
width: Some(640),
|
||||
height: Some(480),
|
||||
size: Some(4096),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
|
||||
assert_eq!(message.author_username, "Unknown");
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.webhook_id, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
vec![Attachment {
|
||||
id: String::new(),
|
||||
url: String::new(),
|
||||
filename: String::new(),
|
||||
nsfw: None,
|
||||
content_type: None,
|
||||
width: None,
|
||||
height: None,
|
||||
size: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_string_and_numeric_snowflakes_without_rounding() {
|
||||
let values = vec![
|
||||
json!({"id": "9007199254740993"}),
|
||||
json!({"id": "10"}),
|
||||
json!({"id": 2}),
|
||||
json!({"id": 9007199254740992_u64}),
|
||||
];
|
||||
let messages = ordered_messages(&values);
|
||||
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
|
||||
|
||||
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
|
||||
assert_eq!(values[0]["id"], "9007199254740993");
|
||||
assert!(ordered_messages(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_message_order_when_ids_are_equal() {
|
||||
let values = [
|
||||
json!({"id": "10", "content": "first"}),
|
||||
json!({"id": 10, "content": "second"}),
|
||||
];
|
||||
let messages = ordered_messages(&values);
|
||||
|
||||
assert_eq!(messages[0].content, "first");
|
||||
assert_eq!(messages[1].content, "second");
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,5 @@ pub mod tooltip;
|
||||
pub mod user_display;
|
||||
pub mod user_profile_badges;
|
||||
pub mod voice;
|
||||
|
||||
pub mod pagination;
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn pager(prev: Option<String>, label: Option<String>, next: Option<String>) -> Markup {
|
||||
pager_with_missing(prev, label, next, false)
|
||||
}
|
||||
|
||||
pub fn pager_with_missing(
|
||||
prev: Option<String>,
|
||||
label: Option<String>,
|
||||
next: Option<String>,
|
||||
omit_missing: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="mt-4 flex items-center justify-between gap-3" {
|
||||
@if let Some(url) = prev {
|
||||
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "← Previous" }
|
||||
} @else if !omit_missing {
|
||||
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "← Previous" }
|
||||
}
|
||||
@if let Some(text) = label { span class="text-sm text-neutral-500" { (text) } }
|
||||
@if let Some(url) = next {
|
||||
a href=(url) class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" { "Next →" }
|
||||
} @else if !omit_missing {
|
||||
span class="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-400" { "Next →" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::BadgeVariant,
|
||||
media::{guild_icon_url, initials},
|
||||
},
|
||||
};
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
@@ -52,93 +61,59 @@ pub fn report_category(category: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
pub(crate) fn report_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
match status {
|
||||
0 => ("Pending", BadgeVariant::Warning),
|
||||
1 => ("Resolved", BadgeVariant::Success),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
pub(crate) fn report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
match report_type {
|
||||
0 => ("Message", BadgeVariant::Info),
|
||||
1 => ("User", BadgeVariant::Default),
|
||||
2 => ("Guild", BadgeVariant::Warning),
|
||||
_ => ("Unknown", BadgeVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
pub(crate) fn message_lookup_href(
|
||||
base: &str,
|
||||
channel_id: &str,
|
||||
message_id: Option<&str>,
|
||||
) -> String {
|
||||
let mut href = format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
urlencoding::encode(channel_id)
|
||||
);
|
||||
if let Some(message_id) = message_id.filter(|id| !id.is_empty()) {
|
||||
href.push_str("&message_id=");
|
||||
href.push_str(&urlencoding::encode(message_id));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
pub(crate) fn reported_guild_icon(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
guild_id: &str,
|
||||
label: &str,
|
||||
) -> Markup {
|
||||
match guild_icon_url(
|
||||
config,
|
||||
guild_id,
|
||||
report.reported_guild_icon_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -74,20 +74,3 @@ pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::HintLink;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "anchor")]
|
||||
fn rejects_a_link_that_points_at_no_anchor() {
|
||||
let _ = HintLink::new("/instance-config", "Instance policy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "label")]
|
||||
fn rejects_a_link_with_no_label() {
|
||||
let _ = HintLink::new("/instance-config#community-creation", " ");
|
||||
}
|
||||
}
|
||||
@@ -16,31 +16,3 @@ pub fn format_user_display(
|
||||
_ => "Unknown".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::user_tag::sync_with_unique_usernames;
|
||||
|
||||
#[test]
|
||||
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
|
||||
sync_with_unique_usernames(true, || {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice"
|
||||
);
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("helper"), Some("4363"), true),
|
||||
"helper#4363"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_instances_keep_the_zero_tag() {
|
||||
assert_eq!(
|
||||
format_user_display(None, Some("alice"), Some("0000"), false),
|
||||
"alice#0000"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,38 +103,3 @@ pub fn user_profile_badges(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
|
||||
user_profile_badges(
|
||||
"https://static.example.com",
|
||||
0,
|
||||
Some(premium_type),
|
||||
Some("2026-01-01"),
|
||||
self_hosted,
|
||||
name,
|
||||
false,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_premium_badges_keep_their_fluxer_labels() {
|
||||
assert!(
|
||||
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
|
||||
);
|
||||
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_premium_badges_use_the_configured_name() {
|
||||
let markup = render(true, Some("Gold"), 1);
|
||||
assert!(markup.contains("Gold subscriber since 2026-01-01"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
|
||||
assert!(!render(true, None, 1).contains("img"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{api::types::FlashLevel, config::AdminConfig, middleware::auth::AuthContext};
|
||||
use crate::{
|
||||
api::types::{FlashLevel, PremiumBranding},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
};
|
||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
|
||||
use super::components::drawer::drawer_controller_script;
|
||||
@@ -11,6 +15,28 @@ use super::layout_scripts::{
|
||||
};
|
||||
use super::layout_sidebar::render_sidebar;
|
||||
|
||||
const DEFAULT_PRODUCT_NAME: &str = "Fluxer";
|
||||
|
||||
pub fn product_name(branding: Option<&PremiumBranding>) -> &str {
|
||||
branding
|
||||
.and_then(|branding| branding.product_name.as_deref())
|
||||
.unwrap_or(DEFAULT_PRODUCT_NAME)
|
||||
}
|
||||
|
||||
pub fn favicon_links(config: &AdminConfig, branding: Option<&PremiumBranding>) -> Markup {
|
||||
let cdn = &config.static_cdn_endpoint;
|
||||
html! {
|
||||
@if let Some(favicon_url) = branding.and_then(|branding| branding.favicon_url.as_deref()) {
|
||||
link rel="icon" href=(favicon_url);
|
||||
} @else {
|
||||
link rel="icon" type="image/x-icon" href={(cdn) "/web/favicon.ico"};
|
||||
link rel="apple-touch-icon" href={(cdn) "/web/apple-touch-icon.png"};
|
||||
link rel="icon" type="image/png" sizes="32x32" href={(cdn) "/web/favicon-32x32.png"};
|
||||
link rel="icon" type="image/png" sizes="16x16" href={(cdn) "/web/favicon-16x16.png"};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_busted_asset(base_path: &str, asset_version: &str, path: &str) -> String {
|
||||
format!("{base_path}{path}?t={asset_version}")
|
||||
}
|
||||
@@ -72,10 +98,7 @@ pub fn admin_layout_ext(
|
||||
title { (title) " ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
|
||||
link rel="icon" type="image/png" sizes="32x32" href={(config.static_cdn_endpoint) "/web/favicon-32x32.png"};
|
||||
link rel="icon" type="image/png" sizes="16x16" href={(config.static_cdn_endpoint) "/web/favicon-16x16.png"};
|
||||
(favicon_links(config, auth.branding.as_ref()))
|
||||
script src=(cache_busted_asset(base, asset_version, "/static/htmx.min.js")) defer {}
|
||||
}
|
||||
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
||||
|
||||
@@ -29,7 +29,6 @@ pub fn render_sidebar(
|
||||
nav data-sidebar-nav="" class="sidebar-scrollbar flex-1 space-y-4 overflow-y-auto p-4" aria-label="Admin sections" {
|
||||
@for section in NAV_SECTIONS {
|
||||
@let visible_items: Vec<_> = section.items.iter()
|
||||
.filter(|item| !(item.hosted_only && config.self_hosted))
|
||||
.filter(|item| acl::has_any_permission(admin_acls, item.required_acls))
|
||||
.filter(|item| {
|
||||
item.active_key != "voice-servers" || inspected_voice_region_id.is_some()
|
||||
|
||||
@@ -7,7 +7,6 @@ pub struct NavItem {
|
||||
pub path: &'static str,
|
||||
pub active_key: &'static str,
|
||||
pub required_acls: &'static [&'static str],
|
||||
pub hosted_only: bool,
|
||||
}
|
||||
|
||||
pub struct NavSection {
|
||||
@@ -17,10 +16,7 @@ pub struct NavSection {
|
||||
|
||||
macro_rules! item {
|
||||
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ]) => {
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: false }
|
||||
};
|
||||
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ], hosted) => {
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: true }
|
||||
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,26 +249,3 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
|
||||
let item = NAV_SECTIONS
|
||||
.iter()
|
||||
.flat_map(|section| section.items)
|
||||
.find(|item| item.active_key == "bulk-actions")
|
||||
.expect("bulk actions nav item");
|
||||
for required in [
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
] {
|
||||
assert!(item.required_acls.contains(&required), "{required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,48 +157,3 @@ pub fn audit_logs_page(
|
||||
};
|
||||
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_type_filter_offers_bulk_jobs() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "",
|
||||
target_type: "bulk_job",
|
||||
access: "",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_filter_survives_form_and_pagination() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "1500000000000000001",
|
||||
target_type: "",
|
||||
access: "read",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<select id="access" name="access""#));
|
||||
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
|
||||
assert_eq!(
|
||||
build_pagination_url("/admin", 1, ¶ms),
|
||||
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
resource_link::{ResourceType, resource_link},
|
||||
table::{table_body, table_cell, table_head, table_header_cell, table_row},
|
||||
},
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
utils::{bigint::format_discriminator, user_tag::display_with_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -43,19 +43,12 @@ fn type_label(target_type: &str) -> String {
|
||||
}
|
||||
|
||||
fn user_label(user: &AuditLogUserSummary) -> String {
|
||||
let tag = user_tag(
|
||||
display_with_tag(
|
||||
user.global_name.as_deref(),
|
||||
&user.username,
|
||||
&format_discriminator(&user.discriminator),
|
||||
false,
|
||||
);
|
||||
match user
|
||||
.global_name
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
Some(global_name) => format!("{global_name} ({tag})"),
|
||||
None => tag,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_label(channel: &AuditLogChannelSummary) -> String {
|
||||
@@ -326,58 +319,3 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"log_id": "1900000000000000001",
|
||||
"admin_user_id": "1500000000000000001",
|
||||
"action": "bulk_schedule_deletion",
|
||||
"target_id": target_id,
|
||||
"target_type": target_type,
|
||||
"audit_log_reason": "Raid cleanup",
|
||||
"created_at": "2026-09-14T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_job_targets_link_to_the_job_detail_page() {
|
||||
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
|
||||
assert!(markup.contains("Bulk job"));
|
||||
assert!(!markup.contains("/admin/users/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_labels_drop_the_zero_tag_only_without_tags() {
|
||||
let admin = AuditLogUserSummary {
|
||||
id: "1500000000000000001".to_owned(),
|
||||
username: "lilith".to_owned(),
|
||||
discriminator: "0".to_owned(),
|
||||
global_name: Some("Lilith".to_owned()),
|
||||
};
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith#0000)");
|
||||
crate::utils::user_tag::sync_with_unique_usernames(true, || {
|
||||
assert_eq!(user_label(&admin), "Lilith (lilith)");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_target_types_stay_unlinked() {
|
||||
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||
assert!(!markup.contains("<a "));
|
||||
assert!(markup.contains("Email domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_action_names_still_render() {
|
||||
let mut retired = entry("user", "1500000000000000002");
|
||||
retired.action = "update_retired_toggle".to_string();
|
||||
let markup = audit_log_table_body("/admin", &[retired]).into_string();
|
||||
assert!(markup.contains("Update retired toggle"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::{DELETION_REASONS, DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::{
|
||||
acl,
|
||||
config::AdminConfig,
|
||||
@@ -19,160 +20,6 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("1", "User requested"),
|
||||
("2", "Other"),
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
("10", "Privacy violation or doxxing"),
|
||||
("11", "Harassment or bullying"),
|
||||
("12", "Payment fraud"),
|
||||
("13", "Child safety violation"),
|
||||
("14", "Billing dispute or abuse"),
|
||||
("15", "Unsolicited explicit content"),
|
||||
("16", "Graphic violence"),
|
||||
("17", "Ban evasion"),
|
||||
("18", "Token or credential scam"),
|
||||
("19", "Inactivity"),
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
];
|
||||
struct UserFlag {
|
||||
name: &'static str,
|
||||
value: u64,
|
||||
}
|
||||
const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
|
||||
UserFlag {
|
||||
name: "STAFF",
|
||||
value: 1 << 0,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PARTNER",
|
||||
value: 1 << 2,
|
||||
},
|
||||
UserFlag {
|
||||
name: "BUG_HUNTER",
|
||||
value: 1 << 3,
|
||||
},
|
||||
UserFlag {
|
||||
name: "FRIENDLY_BOT",
|
||||
value: 1 << 4,
|
||||
},
|
||||
UserFlag {
|
||||
name: "FRIENDLY_BOT_MANUAL_APPROVAL",
|
||||
value: 1 << 5,
|
||||
},
|
||||
UserFlag {
|
||||
name: "SPAMMER",
|
||||
value: 1 << 6,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PROFILE_HIDDEN",
|
||||
value: 1 << 7,
|
||||
},
|
||||
UserFlag {
|
||||
name: "HIGH_GLOBAL_RATE_LIMIT",
|
||||
value: 1 << 33,
|
||||
},
|
||||
UserFlag {
|
||||
name: "DELETED",
|
||||
value: 1 << 34,
|
||||
},
|
||||
UserFlag {
|
||||
name: "SELF_DELETED",
|
||||
value: 1 << 36,
|
||||
},
|
||||
UserFlag {
|
||||
name: "DISABLED",
|
||||
value: 1 << 38,
|
||||
},
|
||||
UserFlag {
|
||||
name: "HAS_SESSION_STARTED",
|
||||
value: 1 << 39,
|
||||
},
|
||||
UserFlag {
|
||||
name: "RATE_LIMIT_BYPASS",
|
||||
value: 1 << 47,
|
||||
},
|
||||
UserFlag {
|
||||
name: "REPORT_BANNED",
|
||||
value: 1 << 48,
|
||||
},
|
||||
UserFlag {
|
||||
name: "VERIFIED_NOT_UNDERAGE",
|
||||
value: 1 << 49,
|
||||
},
|
||||
UserFlag {
|
||||
name: "ACCOUNT_LIMITED",
|
||||
value: 1 << 50,
|
||||
},
|
||||
UserFlag {
|
||||
name: "HAS_DISMISSED_PREMIUM_ONBOARDING",
|
||||
value: 1 << 51,
|
||||
},
|
||||
UserFlag {
|
||||
name: "APP_STORE_REVIEWER",
|
||||
value: 1 << 53,
|
||||
},
|
||||
UserFlag {
|
||||
name: "STAFF_HIDDEN",
|
||||
value: 1 << 57,
|
||||
},
|
||||
UserFlag {
|
||||
name: "AGE_VERIFIED_ADULT",
|
||||
value: 1 << 60,
|
||||
},
|
||||
UserFlag {
|
||||
name: "LIMIT_EXEMPT",
|
||||
value: 1 << 62,
|
||||
},
|
||||
];
|
||||
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
"RAID_DETECTED",
|
||||
"TEXT_CHANNEL_FLEXIBLE_NAMES",
|
||||
"HIDE_OWNER_CROWN",
|
||||
"MORE_EMOJI",
|
||||
"MORE_STICKERS",
|
||||
"UNLIMITED_EMOJI",
|
||||
"UNLIMITED_STICKERS",
|
||||
"EXPRESSION_PURGE_ALLOWED",
|
||||
"VANITY_URL",
|
||||
"DISCOVERABLE",
|
||||
"PARTNERED",
|
||||
"VERIFIED",
|
||||
"VIP_VOICE",
|
||||
"VOICE_E2EE",
|
||||
"UNAVAILABLE_FOR_EVERYONE",
|
||||
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
|
||||
"UNAVAILABLE_HIDDEN",
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED",
|
||||
];
|
||||
|
||||
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
fn guild_feature_label(feature: &str) -> String {
|
||||
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
|
||||
format!("{feature} (deprecated, removal only)")
|
||||
@@ -232,7 +79,7 @@ fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup
|
||||
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@for flag in PATCHABLE_USER_FLAGS {
|
||||
@for flag in crate::admin_flags::USER_FLAGS {
|
||||
(checkbox(prefix, &flag.value.to_string(), flag.name, false, true))
|
||||
}
|
||||
}
|
||||
@@ -413,56 +260,3 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_grid_offers_only_the_opt_in_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
|
||||
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_has_no_preselected_reason() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||
for (value, _) in DELETION_REASONS {
|
||||
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_form_emails_each_user_by_default() {
|
||||
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_the_email_choices() {
|
||||
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
|
||||
assert!(!deletion.contains("Email each user"));
|
||||
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
},
|
||||
utils::bigint::format_discriminator,
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
utils::user_tag::user_tag,
|
||||
utils::user_tag::display_with_tag,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -75,11 +75,12 @@ fn owner_display(
|
||||
let Some(discriminator) = discriminator else {
|
||||
return owner_id.to_owned();
|
||||
};
|
||||
let tag = user_tag(username, &format_discriminator(discriminator), false);
|
||||
match global_name.filter(|value| !value.trim().is_empty()) {
|
||||
Some(global_name) => format!("{global_name} ({tag})"),
|
||||
None => tag,
|
||||
}
|
||||
display_with_tag(
|
||||
global_name,
|
||||
username,
|
||||
&format_discriminator(discriminator),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn nsfw_badge(level: Option<i32>) -> Markup {
|
||||
|
||||
@@ -139,39 +139,3 @@ pub fn gift_codes_page(
|
||||
};
|
||||
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
|
||||
PremiumBranding {
|
||||
name: Some(name.to_owned()),
|
||||
premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
|
||||
let hosted = GiftCodesPremium::from_branding(false, None);
|
||||
assert_eq!(hosted.name, "Plutonium");
|
||||
assert!(!hosted.needs_mirror_mode);
|
||||
let self_hosted = GiftCodesPremium::from_branding(true, None);
|
||||
assert_eq!(self_hosted.name, "Premium");
|
||||
assert!(!self_hosted.needs_mirror_mode);
|
||||
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
|
||||
assert_eq!(gold.name, "Gold");
|
||||
assert!(!gold.needs_mirror_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
|
||||
assert!(
|
||||
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
|
||||
);
|
||||
assert!(
|
||||
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
|
||||
.needs_mirror_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,10 +88,20 @@ pub fn simple_tab_content(
|
||||
csrf_token,
|
||||
admin_acls,
|
||||
),
|
||||
"emojis" => guild_detail_tabs::emojis::emojis_tab(config, &guild_info, &[], csrf_token),
|
||||
"stickers" => {
|
||||
guild_detail_tabs::stickers::stickers_tab(config, &guild_info, &[], csrf_token)
|
||||
}
|
||||
"emojis" => guild_detail_tabs::emojis::guild_assets_tab(
|
||||
config,
|
||||
&guild_info,
|
||||
&[],
|
||||
csrf_token,
|
||||
guild_detail_tabs::emojis::GuildAssetKind::Emoji,
|
||||
),
|
||||
"stickers" => guild_detail_tabs::emojis::guild_assets_tab(
|
||||
config,
|
||||
&guild_info,
|
||||
&[],
|
||||
csrf_token,
|
||||
guild_detail_tabs::emojis::GuildAssetKind::Sticker,
|
||||
),
|
||||
_ => guild_detail_tabs::overview::overview_tab(config, guild, csrf_token),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{Application, GuildInfo},
|
||||
config::AdminConfig,
|
||||
templates::components::page_container::card_with_header,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn applications_tab(
|
||||
config: &AdminConfig,
|
||||
_guild: &GuildInfo,
|
||||
applications: &[Application],
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(card_with_header(
|
||||
&format!("Applications ({})", applications.len()),
|
||||
html! {
|
||||
@if applications.is_empty() {
|
||||
div class="flex flex-col items-center justify-center py-12 text-center" {
|
||||
p class="text-sm font-medium text-neutral-900" {
|
||||
"No applications installed"
|
||||
}
|
||||
p class="mt-1 text-sm text-neutral-500" {
|
||||
"This guild does not have any installed applications."
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
div class="space-y-4" {
|
||||
@for app in applications {
|
||||
(application_card(base, app))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn application_card(base: &str, app: &Application) -> Markup {
|
||||
let bot_display = match (&app.bot_user_id, &app.bot_username, &app.bot_discriminator) {
|
||||
(Some(bid), Some(bname), Some(bdisc)) => {
|
||||
let display = match &app.bot_global_name {
|
||||
Some(gn) if !gn.trim().is_empty() => {
|
||||
format!("{} ({}#{})", gn, bname, bdisc)
|
||||
}
|
||||
_ => format!("{}#{}", bname, bdisc),
|
||||
};
|
||||
Some((bid.clone(), display))
|
||||
}
|
||||
(Some(bid), _, _) => Some((bid.clone(), bid.clone())),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4" {
|
||||
div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" {
|
||||
div class="min-w-0 flex-1 space-y-2" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-base font-medium text-neutral-900" {
|
||||
(app.name)
|
||||
}
|
||||
@if app.bot_user_id.is_some() {
|
||||
span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium \
|
||||
uppercase text-blue-700" {
|
||||
"Bot"
|
||||
}
|
||||
} @else {
|
||||
span class="rounded bg-neutral-200 px-2 py-0.5 text-xs font-medium \
|
||||
uppercase text-neutral-700" {
|
||||
"OAuth2 App"
|
||||
}
|
||||
}
|
||||
}
|
||||
p class="break-all text-sm text-neutral-500" {
|
||||
(app.id)
|
||||
}
|
||||
@if let Some((ref bid, ref display)) = bot_display {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Bot user: "
|
||||
a href={(base) "/users/" (bid)}
|
||||
class="transition-colors hover:text-blue-600 hover:underline" {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No bot user (OAuth2 only)"
|
||||
}
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
(app.oauth2_redirect_uris.len()) " redirect URI"
|
||||
@if app.oauth2_redirect_uris.len() != 1 { "s" }
|
||||
" \u{00b7} Client secret "
|
||||
@if app.has_client_secret { "set" } @else { "not set" }
|
||||
" \u{00b7} Bot token "
|
||||
@if app.has_bot_token { "set" } @else { "not set" }
|
||||
}
|
||||
}
|
||||
a href={(base) "/applications/" (app.id)}
|
||||
class="inline-flex items-center rounded-md bg-brand-primary px-4 py-2 \
|
||||
text-sm font-medium text-white shadow-sm hover:bg-brand-primary-dark" {
|
||||
"View Details"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,25 +11,36 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn emojis_tab(
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum GuildAssetKind {
|
||||
Emoji,
|
||||
Sticker,
|
||||
}
|
||||
|
||||
pub fn guild_assets_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
emojis: &[GuildAssetItem],
|
||||
items: &[GuildAssetItem],
|
||||
csrf_token: &str,
|
||||
kind: GuildAssetKind,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let (title, empty) = match kind {
|
||||
GuildAssetKind::Emoji => ("Emojis", "No custom emojis found for this guild."),
|
||||
GuildAssetKind::Sticker => ("Stickers", "No stickers found for this guild."),
|
||||
};
|
||||
html! {
|
||||
(card_with_header(
|
||||
&format!("Emojis ({})", emojis.len()),
|
||||
&format!("{title} ({})", items.len()),
|
||||
html! {
|
||||
@if emojis.is_empty() {
|
||||
@if items.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No custom emojis found for this guild."
|
||||
(empty)
|
||||
}
|
||||
} @else {
|
||||
div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" {
|
||||
@for emoji in emojis {
|
||||
(emoji_card(base, &guild.id, emoji, csrf_token))
|
||||
@for item in items {
|
||||
(asset_card(base, &guild.id, item, csrf_token, kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,36 +49,55 @@ pub fn emojis_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn emoji_card(base: &str, guild_id: &str, emoji: &GuildAssetItem, csrf_token: &str) -> Markup {
|
||||
fn asset_card(
|
||||
base: &str,
|
||||
guild_id: &str,
|
||||
item: &GuildAssetItem,
|
||||
csrf_token: &str,
|
||||
kind: GuildAssetKind,
|
||||
) -> Markup {
|
||||
let sticker = matches!(kind, GuildAssetKind::Sticker);
|
||||
let (query, input_name, button_label) = match kind {
|
||||
GuildAssetKind::Emoji => (
|
||||
"?tab=emojis&action=delete_emoji",
|
||||
"emoji_id",
|
||||
"Delete Emoji",
|
||||
),
|
||||
GuildAssetKind::Sticker => (
|
||||
"?tab=stickers&action=delete_sticker",
|
||||
"sticker_id",
|
||||
"Delete Sticker",
|
||||
),
|
||||
};
|
||||
html! {
|
||||
div class="overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm" {
|
||||
div class="flex flex-col" {
|
||||
div class="flex h-32 items-center justify-center bg-neutral-100 p-6" {
|
||||
img src=(emoji.media_url) alt=(emoji.name)
|
||||
img src=(item.media_url) alt=(item.name)
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy";
|
||||
}
|
||||
div class="flex-1 space-y-3 px-4 py-3" {
|
||||
div class="flex items-center justify-between" {
|
||||
div class=(if sticker {"flex-1 space-y-1 px-4 py-3"} else {"flex-1 space-y-3 px-4 py-3"}) {
|
||||
div class=(if sticker {"flex items-center justify-between gap-2"} else {"flex items-center justify-between"}) {
|
||||
span class="text-sm font-semibold text-neutral-900" {
|
||||
(emoji.name)
|
||||
(item.name)
|
||||
}
|
||||
@if emoji.animated {
|
||||
(badge("Animated", BadgeVariant::Default))
|
||||
@if sticker || item.animated {
|
||||
(badge(if item.animated {"Animated"} else {"Static"}, BadgeVariant::Default))
|
||||
}
|
||||
}
|
||||
p class="break-words text-xs text-neutral-500" {
|
||||
"ID: " (emoji.id)
|
||||
"ID: " (item.id)
|
||||
}
|
||||
a href={(base) "/users/" (emoji.creator_id)}
|
||||
a href={(base) "/users/" (item.creator_id)}
|
||||
class="text-xs text-blue-600 hover:underline" {
|
||||
"Uploader: " (emoji.creator_id)
|
||||
"Uploader: " (item.creator_id)
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=emojis&action=delete_emoji"} {
|
||||
action={(base) "/guilds/" (guild_id) (query)} class=[sticker.then_some("mt-4")] {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="emoji_id" value=(emoji.id);
|
||||
(danger_button("Delete Emoji"))
|
||||
input type="hidden" name=(input_name) value=(item.id);
|
||||
(danger_button(button_label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::{DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::GuildInfo,
|
||||
@@ -11,49 +12,10 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
"RAID_DETECTED",
|
||||
"TEXT_CHANNEL_FLEXIBLE_NAMES",
|
||||
"HIDE_OWNER_CROWN",
|
||||
"MORE_EMOJI",
|
||||
"MORE_STICKERS",
|
||||
"UNLIMITED_EMOJI",
|
||||
"UNLIMITED_STICKERS",
|
||||
"EXPRESSION_PURGE_ALLOWED",
|
||||
"VANITY_URL",
|
||||
"DISCOVERABLE",
|
||||
"PARTNERED",
|
||||
"VERIFIED",
|
||||
"VIP_VOICE",
|
||||
"VOICE_E2EE",
|
||||
"UNAVAILABLE_FOR_EVERYONE",
|
||||
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
|
||||
"UNAVAILABLE_HIDDEN",
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED",
|
||||
];
|
||||
|
||||
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
|
||||
|
||||
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
fn feature_label(feature: &str) -> String {
|
||||
if DEPRECATED_FEATURES.contains(&feature) {
|
||||
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
|
||||
format!("{feature} (deprecated, no longer enforced)")
|
||||
} else {
|
||||
feature.to_owned()
|
||||
@@ -182,56 +144,3 @@ fn filtered_features() -> Vec<&'static str> {
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn guild_with_features(features: &[&str]) -> GuildInfo {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "1600000000000000001",
|
||||
"name": "Test Guild",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": null,
|
||||
"owner_global_name": null,
|
||||
"owner_discriminator": null,
|
||||
"features": features,
|
||||
"nsfw_level": null,
|
||||
"nsfw": null,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": null,
|
||||
"vanity_url_code": null,
|
||||
}))
|
||||
.expect("guild fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_offers_the_opt_in_clone_features() {
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_keeps_the_deprecated_clone_features_clearable() {
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
|
||||
assert_eq!(
|
||||
feature_label("CLONE_EMOJI_DISABLED"),
|
||||
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
|
||||
);
|
||||
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_view_marks_a_stale_flag_as_deprecated() {
|
||||
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
|
||||
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
|
||||
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
|
||||
assert!(markup.contains("CLONE_STICKER_ENABLED"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager;
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
@@ -11,7 +13,7 @@ use crate::{
|
||||
media::user_avatar_url,
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::{bigint::format_discriminator, user_tag::user_tag},
|
||||
utils::{bigint::format_discriminator, user_tag::display_with_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -64,35 +66,7 @@ pub fn members_tab(
|
||||
}
|
||||
|
||||
@if total_pages > 1 {
|
||||
div class="mt-4 flex items-center justify-between border-t pt-4" {
|
||||
@if has_previous {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page - 1)}
|
||||
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
|
||||
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
|
||||
"\u{2190} Previous"
|
||||
}
|
||||
} @else {
|
||||
span class="inline-flex items-center rounded-md border border-neutral-200 \
|
||||
bg-neutral-50 px-3 py-2 text-sm font-medium text-neutral-400" {
|
||||
"\u{2190} Previous"
|
||||
}
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Page " (u64::from(page) + 1) " of " (total_pages)
|
||||
}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
|
||||
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
|
||||
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
|
||||
"Next \u{2192}"
|
||||
}
|
||||
} @else {
|
||||
span class="inline-flex items-center rounded-md border border-neutral-200 \
|
||||
bg-neutral-50 px-3 py-2 text-sm font-medium text-neutral-400" {
|
||||
"Next \u{2192}"
|
||||
}
|
||||
}
|
||||
}
|
||||
(pager(has_previous.then(|| format!("{base}/guilds/{}?tab=members&page={}", guild.id, page - 1)), Some(format!("Page {} of {total_pages}", u64::from(page) + 1)), next_page.map(|next| format!("{base}/guilds/{}?tab=members&page={next}", guild.id))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,15 +79,12 @@ fn member_card(
|
||||
member: &GuildMember,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let tag = user_tag(
|
||||
let display = display_with_tag(
|
||||
member.user.global_name.as_deref(),
|
||||
&member.user.username,
|
||||
&format_discriminator(&member.user.discriminator),
|
||||
member.user.bot,
|
||||
);
|
||||
let display = match &member.user.global_name {
|
||||
Some(gn) if !gn.trim().is_empty() => format!("{gn} ({tag})"),
|
||||
_ => tag,
|
||||
};
|
||||
let user_url = format!("{base}/users/{}", member.user.id);
|
||||
let avatar_url = user_avatar_url(
|
||||
config,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod applications;
|
||||
pub mod archives;
|
||||
pub mod audit_log;
|
||||
pub mod emojis;
|
||||
@@ -10,26 +9,21 @@ pub mod moderation;
|
||||
pub mod overview;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod stickers;
|
||||
pub mod threads;
|
||||
|
||||
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
|
||||
use crate::{api::types::GuildDetailInfo, utils::user_tag::display_with_tag};
|
||||
|
||||
pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
|
||||
let Some(username) = guild.owner_username.as_deref() else {
|
||||
let (Some(username), Some(discriminator)) = (
|
||||
guild.owner_username.as_deref(),
|
||||
guild.owner_discriminator.as_deref(),
|
||||
) else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(global_name) = guild
|
||||
.owner_global_name
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
{
|
||||
format!("{global_name} ({tag})")
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
display_with_tag(
|
||||
guild.owner_global_name.as_deref(),
|
||||
username,
|
||||
discriminator,
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::report_category::report_status;
|
||||
|
||||
use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::{
|
||||
components::page_container::card_with_header,
|
||||
pages::user_detail_tabs::reports::{
|
||||
format_status, report_tab_table, reported_at_cell, reporter_label,
|
||||
type_and_category_cell,
|
||||
report_tab_table, reported_at_cell, reporter_label, type_and_category_cell,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -94,11 +95,11 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
|
||||
(format_status(report.status))
|
||||
(report_status(report.status).0)
|
||||
}
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
|
||||
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
|
||||
(format_status(report.status))
|
||||
(report_status(report.status).0)
|
||||
}
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border border-neutral-300 \
|
||||
@@ -110,49 +111,3 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
@@ -294,32 +294,3 @@ fn readonly_field(label: &str, value: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn guild() -> GuildDetailInfo {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1500000000000000001",
|
||||
"owner_id": "1400000000000000001",
|
||||
"name": "Guild",
|
||||
"verification_level": 1
|
||||
}))
|
||||
.expect("valid guild detail")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_claimed_account_in_username_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), true).into_string();
|
||||
assert!(markup.contains("Low (claimed account)"));
|
||||
assert!(!markup.contains("verified email"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_verification_names_a_verified_email_in_email_mode() {
|
||||
let markup = settings_tab_readonly(&guild(), false).into_string();
|
||||
assert!(markup.contains("Low (verified email)"));
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{GuildAssetItem, GuildInfo},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{csrf_input, danger_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn stickers_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
stickers: &[GuildAssetItem],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(card_with_header(
|
||||
&format!("Stickers ({})", stickers.len()),
|
||||
html! {
|
||||
@if stickers.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No stickers found for this guild."
|
||||
}
|
||||
} @else {
|
||||
div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" {
|
||||
@for sticker in stickers {
|
||||
(sticker_card(base, &guild.id, sticker, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn sticker_card(base: &str, guild_id: &str, sticker: &GuildAssetItem, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
div class="overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm" {
|
||||
div class="flex flex-col" {
|
||||
div class="flex h-32 items-center justify-center bg-neutral-100 p-6" {
|
||||
img src=(sticker.media_url) alt=(sticker.name)
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy";
|
||||
}
|
||||
div class="flex-1 space-y-1 px-4 py-3" {
|
||||
div class="flex items-center justify-between gap-2" {
|
||||
span class="text-sm font-semibold text-neutral-900" {
|
||||
(sticker.name)
|
||||
}
|
||||
(badge(
|
||||
if sticker.animated { "Animated" } else { "Static" },
|
||||
BadgeVariant::Default,
|
||||
))
|
||||
}
|
||||
p class="break-words text-xs text-neutral-500" {
|
||||
"ID: " (sticker.id)
|
||||
}
|
||||
a href={(base) "/users/" (sticker.creator_id)}
|
||||
class="text-xs text-blue-600 hover:underline" {
|
||||
"Uploader: " (sticker.creator_id)
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=stickers&action=delete_sticker"}
|
||||
class="mt-4" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="sticker_id" value=(sticker.id);
|
||||
(danger_button("Delete Sticker"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager;
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::GuildInfo,
|
||||
@@ -15,7 +17,7 @@ use crate::{
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::{forms::parse_comma_separated, user_tag::user_tag},
|
||||
utils::{forms::parse_comma_separated, user_tag::display_with_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -164,38 +166,14 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
|
||||
.page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
html! {
|
||||
div class="mt-6 flex items-center justify-center gap-3" {
|
||||
@if has_previous {
|
||||
a href=(pagination_url(&config.base_path, params, params.page - 1))
|
||||
class="rounded-lg border border-neutral-300 bg-white px-6 py-2 \
|
||||
font-medium text-neutral-900 text-sm no-underline \
|
||||
transition-colors hover:bg-neutral-50" {
|
||||
"\u{2190} Previous"
|
||||
}
|
||||
} @else {
|
||||
div class="cursor-not-allowed rounded-lg border border-neutral-200 \
|
||||
bg-neutral-100 px-6 py-2 font-medium text-neutral-400 text-sm" {
|
||||
"\u{2190} Previous"
|
||||
}
|
||||
}
|
||||
span class="text-neutral-600 text-sm" {
|
||||
"Page " (u64::from(params.page) + 1) " of " (total_pages)
|
||||
}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href=(pagination_url(&config.base_path, params, next_page))
|
||||
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
|
||||
text-white no-underline transition-colors hover:bg-neutral-800" {
|
||||
"Next \u{2192}"
|
||||
}
|
||||
} @else {
|
||||
div class="cursor-not-allowed rounded-lg bg-neutral-100 px-6 py-2 \
|
||||
font-medium text-neutral-400 text-sm" {
|
||||
"Next \u{2192}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pager(
|
||||
has_previous.then(|| pagination_url(&config.base_path, params, params.page - 1)),
|
||||
Some(format!(
|
||||
"Page {} of {total_pages}",
|
||||
u64::from(params.page) + 1
|
||||
)),
|
||||
next_page.map(|page| pagination_url(&config.base_path, params, page)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
|
||||
@@ -262,23 +240,19 @@ fn render_guilds_cards(config: &AdminConfig, guilds: &[GuildInfo]) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_display(guild: &GuildInfo) -> String {
|
||||
let Some(username) = guild.owner_username.as_deref() else {
|
||||
pub(crate) fn owner_display(guild: &GuildInfo) -> String {
|
||||
let (Some(username), Some(discriminator)) = (
|
||||
guild.owner_username.as_deref(),
|
||||
guild.owner_discriminator.as_deref(),
|
||||
) else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
|
||||
return guild.owner_id.clone();
|
||||
};
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(global_name) = guild
|
||||
.owner_global_name
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
{
|
||||
format!("{global_name} ({tag})")
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
display_with_tag(
|
||||
guild.owner_global_name.as_deref(),
|
||||
username,
|
||||
discriminator,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn guild_icon(config: &AdminConfig, guild: &GuildInfo) -> Markup {
|
||||
|
||||
@@ -464,189 +464,3 @@ pub fn premium_billing_section(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn operator_billing() -> InstanceBillingResponse {
|
||||
InstanceBillingResponse {
|
||||
enabled: Some(true),
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
stripe_secret_key_stored: true,
|
||||
stripe_webhook_secret_stored: true,
|
||||
default_currency: Some("GBP".to_owned()),
|
||||
prices: Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1GbpM".to_owned()),
|
||||
yearly: Some("price_1GbpY".to_owned()),
|
||||
gift_1_month: None,
|
||||
gift_1_year: Some("price_1GbpG".to_owned()),
|
||||
},
|
||||
)])),
|
||||
country_currencies: Some(BTreeMap::from([
|
||||
("GB".to_owned(), "GBP".to_owned()),
|
||||
("IE".to_owned(), "GBP".to_owned()),
|
||||
])),
|
||||
legacy_prices: Some(BTreeMap::from([(
|
||||
"monthly_GBP".to_owned(),
|
||||
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
|
||||
)])),
|
||||
billing_active: true,
|
||||
stripe_serviceable: true,
|
||||
catalog_mode: BillingCatalogMode::Operator,
|
||||
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
|
||||
automatic_tax: None,
|
||||
tax_id_collection: Some(true),
|
||||
terms_consent_required: Some(false),
|
||||
effective_automatic_tax: false,
|
||||
effective_tax_id_collection: true,
|
||||
effective_terms_consent_required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn branding(name: &str) -> AppBrandingConfigResponse {
|
||||
AppBrandingConfigResponse {
|
||||
premium_product_name: name.to_owned(),
|
||||
premium_info_url: Some("https://example.com/gold".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_renders_every_field_and_one_empty_price_row() {
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Gold"),
|
||||
&operator_billing(),
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
|
||||
assert!(markup.contains("data-admin-result-form=\"true\""));
|
||||
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(markup.contains("name=\"billing_automatic_tax\""));
|
||||
assert!(markup.contains("name=\"billing_tax_id_collection\""));
|
||||
assert!(markup.contains("name=\"billing_terms_consent_required\""));
|
||||
assert!(markup.contains("Customer portal"));
|
||||
assert!(markup.contains("name=\"billing_premium_product_name\""));
|
||||
assert!(markup.contains("value=\"Gold\""));
|
||||
assert!(markup.contains("value=\"https://example.com/gold\""));
|
||||
assert!(markup.contains("name=\"billing_enabled\""));
|
||||
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
|
||||
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
|
||||
assert!(markup.contains("Billing active"));
|
||||
assert!(!markup.contains("Purchases are unavailable"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
|
||||
assert_eq!(
|
||||
markup.matches("name=\"billing_price_gift_1_year\"").count(),
|
||||
2
|
||||
);
|
||||
assert!(markup.contains("value=\"price_1GbpG\""));
|
||||
assert!(markup.contains("GB=GBP\nIE=GBP"));
|
||||
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(!markup.contains("sk_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_secrets_have_no_clear_checkbox() {
|
||||
let billing = InstanceBillingResponse::default();
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Everyone,
|
||||
)
|
||||
.into_string();
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
|
||||
assert!(markup.contains("Switch the premium model to Mirror"));
|
||||
assert!(markup.contains("Catalog: environment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_secrets_are_labelled_and_cannot_be_cleared() {
|
||||
let billing = InstanceBillingResponse {
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
..Default::default()
|
||||
};
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("Stripe secret key from environment"));
|
||||
assert!(markup.contains("Webhook secret from environment"));
|
||||
assert!(markup.contains("Set from the environment"));
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_selects_reflect_the_stored_value() {
|
||||
let render = |stored| {
|
||||
tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
stored,
|
||||
"",
|
||||
)
|
||||
.into_string()
|
||||
};
|
||||
assert!(
|
||||
render(None)
|
||||
.contains("<option value=\"default\" selected>Use environment setting</option>")
|
||||
);
|
||||
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockers_explain_why_billing_is_inactive() {
|
||||
let mut billing = InstanceBillingResponse::default();
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Everyone),
|
||||
vec![
|
||||
"the premium model is Everyone, so there is no paid tier to sell",
|
||||
"billing is not enabled",
|
||||
"no Stripe secret key is set",
|
||||
]
|
||||
);
|
||||
billing.effective_enabled = true;
|
||||
billing.stripe_secret_key_set = true;
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec![
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
]
|
||||
);
|
||||
billing.catalog_mode = BillingCatalogMode::Operator;
|
||||
billing.prices = Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1A".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec!["no currency has both a monthly and a yearly price ID"]
|
||||
);
|
||||
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -158,6 +158,7 @@ pub fn instance_config_page(
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
instance_config.billing.enabled == Some(true),
|
||||
))
|
||||
},
|
||||
))
|
||||
@@ -199,7 +200,12 @@ pub fn instance_config_page(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
html! {
|
||||
(media_config_section(base, csrf_token, &instance_config.media))
|
||||
(media_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.media,
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
@@ -264,6 +270,7 @@ fn policy_config_section(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
@@ -274,7 +281,7 @@ fn policy_config_section(
|
||||
div class="space-y-8" {
|
||||
(single_community_form(base, csrf_token, policy))
|
||||
(direct_messages_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name, billing_enabled))
|
||||
(community_creation_form(base, csrf_token, policy))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
@@ -373,6 +380,7 @@ fn premium_mode_form(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
@@ -386,6 +394,16 @@ fn premium_mode_form(
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.as_str()),
|
||||
], policy.premium_mode.as_str()))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Limits you have not edited follow the model. Limits you changed under Limit \
|
||||
Config keep your values in both models."
|
||||
}
|
||||
@if billing_enabled && matches!(policy.premium_mode, PremiumMode::Mirror) {
|
||||
p class="text-sm text-amber-700" {
|
||||
"Billing is enabled. Disable it under Premium & billing before switching \
|
||||
the premium model to Everyone."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium model"))
|
||||
}))
|
||||
@@ -528,6 +546,16 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn smtp_tls_mode_summary(mode: &str) -> &str {
|
||||
match mode {
|
||||
"implicit" => "implicit TLS",
|
||||
"starttls" => "required STARTTLS",
|
||||
"opportunistic" => "STARTTLS when the server offers it",
|
||||
"none" => "no encryption",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
|
||||
let description = match account_identity.mode {
|
||||
AccountIdentityMode::Username => {
|
||||
@@ -671,15 +699,34 @@ fn integrations_config_section(
|
||||
))
|
||||
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
|
||||
}
|
||||
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
|
||||
(select_input("integration_smtp_tls_mode", "SMTP encryption", &[
|
||||
("", "Automatic"),
|
||||
("implicit", "Implicit TLS"),
|
||||
("starttls", "STARTTLS, required"),
|
||||
("opportunistic", "STARTTLS, when the server offers it"),
|
||||
("none", "None"),
|
||||
], integrations.email.smtp.tls_mode.as_deref().unwrap_or("")))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Automatic follows FLUXER_EMAIL_SMTP_TLS_MODE when it is set, then the older FLUXER_EMAIL_SMTP_SECURE, where true means implicit TLS on any port. With neither set it uses implicit TLS on port 465 and requires STARTTLS on every other port."
|
||||
}
|
||||
@if let Some(mode) = integrations.email.smtp.effective_tls_mode.as_deref() {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Outgoing email currently uses " (smtp_tls_mode_summary(mode))
|
||||
@if let Some(port) = integrations.email.smtp.port { " on port " (port) }
|
||||
"."
|
||||
}
|
||||
}
|
||||
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
|
||||
span { "Test SMTP connection" }
|
||||
span { "Save and test SMTP" }
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The test saves this form first, then connects with the saved settings, the same ones outgoing email uses."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +786,12 @@ fn integrations_config_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaResponse) -> Markup {
|
||||
fn media_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
media: &InstanceMediaResponse,
|
||||
self_hosted: bool,
|
||||
) -> Markup {
|
||||
let decay = &media.attachment_decay;
|
||||
let effective = &decay.effective;
|
||||
let enabled = decay.enabled.unwrap_or(effective.enabled);
|
||||
@@ -767,9 +819,14 @@ fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaRespo
|
||||
.renew_window_days
|
||||
.unwrap_or(effective.renew_window_days)
|
||||
.to_string();
|
||||
let description = if self_hosted {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. On a self-hosted instance, changes also apply to attachments uploaded earlier, which are kept for at least the renew window before a new or shorter expiry deletes them."
|
||||
} else {
|
||||
"Expire eligible attachments using size-based lifetimes. This is enabled by default and applies at runtime. Changes apply to attachments uploaded from then on."
|
||||
};
|
||||
section_card_with_description(
|
||||
"Media Expiry",
|
||||
"Expire eligible attachments using size-based lifetimes. This is disabled by default and applies at runtime.",
|
||||
description,
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_media"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -893,9 +950,9 @@ fn app_public_config_section(
|
||||
))
|
||||
(text_input(
|
||||
"app_theme_color",
|
||||
"Theme Color",
|
||||
"Theme Color (hex)",
|
||||
app_public.branding.theme_color.as_deref().unwrap_or(""),
|
||||
"#5865f2",
|
||||
"#4641d9",
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
@@ -2142,224 +2199,3 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_instances_hide_email_delivery_and_the_smtp_test() {
|
||||
let integrations = InstanceIntegrationsResponse::default();
|
||||
let username = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Username,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(!username.contains("Email delivery"));
|
||||
assert!(!username.contains("test_smtp"));
|
||||
assert!(!username.contains("integration_email_present"));
|
||||
assert!(username.contains("Bluesky OAuth"));
|
||||
|
||||
let email = integrations_config_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Email,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(email.contains("Email delivery"));
|
||||
assert!(email.contains("test_smtp"));
|
||||
assert!(email.contains(r#"name="integration_email_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_has_no_tag_choice_in_username_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Username,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Sign-in Method"));
|
||||
assert!(markup.contains("Username"));
|
||||
assert!(markup.contains("Fixed"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("username tags"));
|
||||
assert!(!markup.contains("<form"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_random_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("Random tags"));
|
||||
assert!(!markup.contains("No tags"));
|
||||
assert!(markup.contains("username tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_no_tags_in_email_mode() {
|
||||
let markup = account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked: Some(true),
|
||||
tag_style: TagStyle::None,
|
||||
})
|
||||
.into_string();
|
||||
assert!(markup.contains("No tags"));
|
||||
assert!(!markup.contains("Random tags"));
|
||||
assert!(!markup.contains("<input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identity_section_shows_the_lock_state_only_when_known() {
|
||||
let render = |locked| {
|
||||
account_identity_section(&AccountIdentityConfigResponse {
|
||||
mode: AccountIdentityMode::Email,
|
||||
locked,
|
||||
tag_style: TagStyle::Random,
|
||||
})
|
||||
.into_string()
|
||||
};
|
||||
let unlocked = render(Some(false));
|
||||
assert!(unlocked.contains("Not fixed yet"));
|
||||
let unknown = render(None);
|
||||
assert!(!unknown.contains("Fixed"));
|
||||
assert!(!unknown.contains("Not fixed yet"));
|
||||
assert!(unknown.contains("Random tags"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captcha_section_posts_the_switch_and_difficulty_fields() {
|
||||
let markup =
|
||||
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
|
||||
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
|
||||
assert!(markup.contains(r#"name="captcha_enabled""#));
|
||||
assert!(markup.contains(r#"name="captcha_cost""#));
|
||||
assert!(markup.contains(r#"name="captcha_max_counter""#));
|
||||
assert!(markup.contains("about 3.6 s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
|
||||
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
|
||||
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
assert!(markup.contains("name=\"domain_migration_rollout_country_codes\""));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
|
||||
let voice_p2p = VoiceP2pConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 250,
|
||||
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
|
||||
max_participants: 3,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..VoiceP2pConfigResponse::default()
|
||||
};
|
||||
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
|
||||
assert!(markup.contains("Peer-to-peer voice"));
|
||||
assert!(markup.contains("action=update_voice_p2p"));
|
||||
assert!(markup.contains("name=\"voice_p2p_enabled\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
|
||||
assert!(markup.contains("value=\"3\""));
|
||||
assert!(markup.contains("min=\"2\""));
|
||||
assert!(markup.contains("max=\"4\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
|
||||
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
|
||||
assert!(markup.contains("value=\"SE, NO\""));
|
||||
assert!(markup.contains("2 of 250 stored"));
|
||||
assert!(markup.contains("Config version 3"));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("anonymous_rollout_basis_points"));
|
||||
assert!(!markup.contains("standalone_forwarding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
};
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_mode_options_use_the_configured_premium_name() {
|
||||
let markup =
|
||||
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
|
||||
.into_string();
|
||||
assert!(markup.contains("Mirror (Free and Gold tiers)"));
|
||||
assert!(markup.contains("Everyone (every member gets Gold limits)"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_flags_a_list_at_its_cap() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
}
|
||||
@@ -196,22 +196,3 @@ pub(crate) fn next_page_link(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::status_badge;
|
||||
|
||||
#[test]
|
||||
fn status_badges_use_us_spelling_and_readable_labels() {
|
||||
let canceled = status_badge("cancelled").into_string();
|
||||
assert!(canceled.contains(">Canceled<"));
|
||||
assert!(!canceled.contains("Cancelled"));
|
||||
assert!(
|
||||
status_badge("deadletter")
|
||||
.into_string()
|
||||
.contains(">Dead-letter<")
|
||||
);
|
||||
assert!(status_badge("running").into_string().contains(">Running<"));
|
||||
assert!(status_badge("mystery").into_string().contains(">mystery<"));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, secondary_button,
|
||||
submit_button,
|
||||
},
|
||||
page_container::{card_with_header, page_header},
|
||||
tooltip,
|
||||
},
|
||||
@@ -25,6 +28,7 @@ const CATEGORY_ORDER: &[&str] = &[
|
||||
"expressions",
|
||||
"files",
|
||||
"social",
|
||||
"media",
|
||||
];
|
||||
|
||||
pub fn limit_config_page(
|
||||
@@ -81,7 +85,7 @@ fn render_limit_page(
|
||||
div class="space-y-6" {
|
||||
(rule_tabs(config, &rules, active_rule_id, can_update))
|
||||
@if let Some(rule) = active_rule {
|
||||
(rule_header(config, csrf_token, rule, can_update))
|
||||
(rule_header(&config.base_path, csrf_token, rule, can_update, response.defaults.contains_key(&rule.id)))
|
||||
(rule_editor(config, csrf_token, response, rule, can_update))
|
||||
} @else {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
||||
@@ -151,11 +155,18 @@ fn rule_tabs(
|
||||
}
|
||||
|
||||
fn rule_header(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
rule: &LimitRule,
|
||||
can_update: bool,
|
||||
has_defaults: bool,
|
||||
) -> Markup {
|
||||
let can_reset = can_update
|
||||
&& has_defaults
|
||||
&& rule
|
||||
.modified_fields
|
||||
.as_ref()
|
||||
.is_some_and(|fields| !fields.is_empty());
|
||||
card_with_header(
|
||||
"",
|
||||
html! {
|
||||
@@ -176,11 +187,20 @@ fn rule_header(
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
@if can_reset {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=reset&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(secondary_button("Reset to Defaults"))
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::AdminConfig;
|
||||
use crate::{
|
||||
api::types::PremiumBranding,
|
||||
config::AdminConfig,
|
||||
templates::layout::{favicon_links, product_name},
|
||||
};
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
|
||||
pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
pub fn login_page(
|
||||
config: &AdminConfig,
|
||||
branding: Option<&PremiumBranding>,
|
||||
error_message: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
@@ -14,7 +22,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
title { "Login ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href={(base) "/static/app.css"};
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
(favicon_links(config, branding))
|
||||
}
|
||||
body class="flex min-h-[100dvh] items-center justify-center bg-neutral-50 p-4" {
|
||||
main class="w-full max-w-sm" {
|
||||
@@ -38,7 +46,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
bg-neutral-900 text-white hover:bg-neutral-800 \
|
||||
px-4 py-2 text-base w-full sm:w-fit \
|
||||
focus:ring-offset-white" {
|
||||
span { "Sign in with Fluxer" }
|
||||
span { "Sign in with " (product_name(branding)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>) {
|
||||
@@ -640,126 +576,3 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
.join("&");
|
||||
format!("{}/reports?{}", config.base_path, query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> ReportEntry {
|
||||
let mut value = json!({
|
||||
"report_id": "1556709309709027556",
|
||||
"reporter_id": "1556709306000000001",
|
||||
"reporter_username": "reporter_80f33d09e88a",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "5193",
|
||||
"reported_at": "2026-10-06T04:33:00Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1556709306000000002",
|
||||
"reported_user_username": "target_bc57b33ca5c4",
|
||||
"reported_user_discriminator": "8316"
|
||||
});
|
||||
for key in [
|
||||
"reporter_tag",
|
||||
"reporter_email",
|
||||
"reporter_full_legal_name",
|
||||
"reporter_country_of_residence",
|
||||
"additional_info",
|
||||
"reported_user_tag",
|
||||
"reported_user_global_name",
|
||||
"reported_user_avatar_hash",
|
||||
"reported_guild_id",
|
||||
"reported_guild_name",
|
||||
"reported_guild_icon_hash",
|
||||
"reported_message_id",
|
||||
"reported_channel_id",
|
||||
"reported_channel_name",
|
||||
"reported_channel_nsfw",
|
||||
"reported_guild_invite_code",
|
||||
"reported_guild_nsfw_level",
|
||||
"reported_guild_nsfw",
|
||||
"reported_guild_content_warning_level",
|
||||
"reported_guild_content_warning_text",
|
||||
"reported_channel_nsfw_override",
|
||||
"reported_channel_content_warning_level",
|
||||
"reported_channel_content_warning_text",
|
||||
"reported_channel_effective_nsfw",
|
||||
"reported_channel_effective_content_warning_level",
|
||||
"reported_channel_effective_content_warning_text",
|
||||
"resolved_at",
|
||||
"resolved_by_admin_id",
|
||||
"public_comment",
|
||||
"mutual_dm_channel_id",
|
||||
"message_context",
|
||||
] {
|
||||
value[key] = serde_json::Value::Null;
|
||||
}
|
||||
serde_json::from_value(value).expect("report fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_layout_moves_date_and_status_into_visible_columns() {
|
||||
let markup = render_reports_table(&test_config(), &[report()]).into_string();
|
||||
assert!(markup.contains(
|
||||
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
|
||||
));
|
||||
assert!(markup.contains(
|
||||
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
|
||||
));
|
||||
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap xl:table-cell")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(
|
||||
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("[overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::admin_layout,
|
||||
layout::{admin_layout, product_name},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
@@ -37,9 +37,9 @@ pub fn system_dm_page(
|
||||
"Send a system DM"
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Sent from the official Fluxer system account. Each recipient \
|
||||
will receive the same content as a DM. Progress is observable \
|
||||
on the "
|
||||
"Sent from the official " (product_name(auth.branding.as_ref()))
|
||||
" system account. Each recipient will receive the same content \
|
||||
as a DM. Progress is observable on the "
|
||||
a href=(jobs_url) class="font-medium text-neutral-900 hover:underline" {
|
||||
"Jobs page"
|
||||
}
|
||||
|
||||
@@ -173,47 +173,3 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,9 @@ fn render_user_detail(
|
||||
@if let Some(banner) = banner_url {
|
||||
div class="mb-4 space-y-2" {
|
||||
a href=(banner) target="_blank" rel="noreferrer noopener"
|
||||
class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" {
|
||||
img src=(banner) alt={(user.username) "'s banner"}
|
||||
class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48"
|
||||
class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
|
||||
loading="lazy";
|
||||
}
|
||||
@if let Some(hash) = &user.banner {
|
||||
|
||||
@@ -6,7 +6,12 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> Markup {
|
||||
pub fn applications_tab(
|
||||
config: &AdminConfig,
|
||||
applications: &[Application],
|
||||
empty_title: &str,
|
||||
empty_body: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(card_with_header(
|
||||
@@ -15,10 +20,10 @@ pub fn applications_tab(config: &AdminConfig, applications: &[Application]) -> M
|
||||
@if applications.is_empty() {
|
||||
div class="flex flex-col items-center justify-center py-12 text-center" {
|
||||
p class="text-sm font-medium text-neutral-900" {
|
||||
"No applications owned"
|
||||
(empty_title)
|
||||
}
|
||||
p class="mt-1 text-sm text-neutral-500" {
|
||||
"This user does not own any applications."
|
||||
(empty_body)
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::{
|
||||
api::types::DmChannel, config::AdminConfig,
|
||||
templates::components::page_container::card_with_header,
|
||||
@@ -144,27 +146,14 @@ fn dm_pagination(
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
div class="flex items-center justify-center gap-2 rounded-lg border \
|
||||
border-neutral-200 bg-neutral-50 px-4 py-2" {
|
||||
@if has_previous {
|
||||
@if let Some(fid) = first_id {
|
||||
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_after=" (fid)}
|
||||
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
|
||||
transition-colors hover:bg-white hover:text-neutral-900" {
|
||||
"Previous"
|
||||
}
|
||||
}
|
||||
}
|
||||
@if has_next {
|
||||
@if let Some(lid) = last_id {
|
||||
a href={(base) "/users/" (user_id) "?tab=dm_history&dm_limit=" (limit) "&dm_before=" (lid)}
|
||||
class="rounded px-3 py-1 text-sm font-medium text-neutral-600 \
|
||||
transition-colors hover:bg-white hover:text-neutral-900" {
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pager_with_missing(
|
||||
first_id.filter(|_| has_previous).map(|id| {
|
||||
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_after={id}")
|
||||
}),
|
||||
None,
|
||||
last_id.filter(|_| has_next).map(|id| {
|
||||
format!("{base}/users/{user_id}?tab=dm_history&dm_limit={limit}&dm_before={id}")
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::DELETION_REASONS;
|
||||
use crate::utils::plural::{count_noun, noun_for};
|
||||
use crate::{
|
||||
acl,
|
||||
@@ -30,31 +31,6 @@ const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
("0", "Permanent"),
|
||||
];
|
||||
|
||||
const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("1", "User requested"),
|
||||
("2", "Other"),
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
("10", "Privacy violation or doxxing"),
|
||||
("11", "Harassment or bullying"),
|
||||
("12", "Payment fraud"),
|
||||
("13", "Child safety violation"),
|
||||
("14", "Billing dispute or abuse"),
|
||||
("15", "Unsolicited explicit content"),
|
||||
("16", "Graphic violence"),
|
||||
("17", "Ban evasion"),
|
||||
("18", "Token or credential scam"),
|
||||
("19", "Inactivity"),
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
];
|
||||
|
||||
pub struct CurrentBan<'a> {
|
||||
pub entry: &'a AuditLogEntry,
|
||||
pub notes: Vec<&'a AuditLogEntry>,
|
||||
@@ -749,164 +725,3 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn user(extra: Value) -> AdminUser {
|
||||
let mut value =
|
||||
json!({"id": "1500000000000000001", "username": "target", "discriminator": "0001"});
|
||||
if let (Some(target), Some(fields)) = (value.as_object_mut(), extra.as_object()) {
|
||||
target.extend(fields.clone());
|
||||
}
|
||||
serde_json::from_value(value).expect("valid admin user")
|
||||
}
|
||||
|
||||
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1400000000000000001",
|
||||
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
|
||||
"action": action,
|
||||
"target_id": "1500000000000000001",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": reason,
|
||||
"metadata": metadata,
|
||||
"created_at": "2026-09-01T10:00:00.000Z"
|
||||
}))
|
||||
.expect("valid audit log entry")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
|
||||
let target = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3,
|
||||
"deletion_public_reason": "Spam",
|
||||
"deletion_audit_log_reason": "Report batch 12",
|
||||
"deletion_scheduled_by": "1400000000000000001",
|
||||
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
|
||||
}));
|
||||
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
|
||||
let markup =
|
||||
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
|
||||
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
|
||||
assert!(markup.contains("lilith"));
|
||||
assert!(markup.contains("Report batch 12"));
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
|
||||
)
|
||||
);
|
||||
assert!(markup.contains(r#"name="notify_user" value="true""#));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
|
||||
assert!(markup.contains(r#"name="private_reason" required"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_makes_the_reason_an_explicit_choice() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
|
||||
assert!(!markup.contains(r#"<option value="1" selected>"#));
|
||||
assert!(!markup.contains("replace_pending_deletion_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_form_emails_the_user_by_default() {
|
||||
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_ban_form_emails_the_user_by_default() {
|
||||
let markup =
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_offers_no_email_and_sends_none() {
|
||||
let pending = user(json!({
|
||||
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
|
||||
"deletion_reason_code": 3
|
||||
}));
|
||||
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let forms = [
|
||||
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
|
||||
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
|
||||
];
|
||||
for markup in &forms {
|
||||
assert!(!markup.contains("Email the user"));
|
||||
assert!(!markup.contains(r#"name="notify_user" value="true""#));
|
||||
}
|
||||
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(!forms[1].contains("notify_user"));
|
||||
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unban_form_separates_the_public_and_private_reasons() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
|
||||
assert!(markup.contains("?action=unban&tab=moderation"));
|
||||
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
|
||||
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
|
||||
assert!(markup.contains(
|
||||
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
|
||||
));
|
||||
assert!(markup.contains(r#"name="private_reason""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
|
||||
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
|
||||
let logs = vec![
|
||||
entry(
|
||||
"3",
|
||||
"annotate_ban",
|
||||
"Also sent links",
|
||||
json!({"ban_audit_log_id": "2"}),
|
||||
),
|
||||
entry(
|
||||
"2",
|
||||
"temp_ban",
|
||||
"Regel § 3",
|
||||
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"1",
|
||||
"temp_ban",
|
||||
"Older ban",
|
||||
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
|
||||
),
|
||||
entry(
|
||||
"4",
|
||||
"annotate_ban",
|
||||
"Old note",
|
||||
json!({"ban_audit_log_id": "1"}),
|
||||
),
|
||||
];
|
||||
let ban = find_current_ban(&target, &logs).expect("current ban");
|
||||
assert_eq!(ban.entry.log_id, "2");
|
||||
assert_eq!(
|
||||
ban.notes
|
||||
.iter()
|
||||
.map(|note| note.log_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["3"]
|
||||
);
|
||||
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
|
||||
assert!(markup.contains("Regel § 3"));
|
||||
assert!(markup.contains("Also sent links"));
|
||||
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
|
||||
assert!(markup.contains("?action=annotate_ban&tab=moderation"));
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,10 @@ fn traits_form(
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
|
||||
(custom_traits.join("\n"))
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"The premium trait follows each member's subscription or gift and cannot be \
|
||||
assigned here."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Traits"))
|
||||
@@ -584,84 +588,3 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: crate::config::RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: true,
|
||||
proxy: crate::config::ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_email_row(username_sign_in: bool) -> String {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"email": "[email protected]"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
|
||||
render_overview_tab(
|
||||
&test_config(),
|
||||
&user,
|
||||
&acls,
|
||||
"csrf",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
username_sign_in,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_hides_the_email_row() {
|
||||
assert!(!render_email_row(true).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_shows_the_email_row() {
|
||||
assert!(render_email_row(false).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_active_and_grace_end_use_the_panel_date_format() {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"last_active_at": "2026-10-06T20:23:38.591Z",
|
||||
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let html =
|
||||
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
|
||||
.into_string();
|
||||
assert!(!html.contains("2026-10-06T20:23"));
|
||||
assert!(!html.contains("2026-11-01T08:00"));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::templates::components::report_category::{report_status, report_type};
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
@@ -119,47 +123,13 @@ fn report_section(
|
||||
}
|
||||
|
||||
@if has_previous || next_page.is_some() {
|
||||
div class="flex justify-center gap-2" {
|
||||
@if has_previous {
|
||||
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-2 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"Previous"
|
||||
}
|
||||
}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-2 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
(pager_with_missing(has_previous.then(|| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {current_page - 1} else {sent_page}, if kind == "received" {current_page - 1} else {received_page})), None, next_page.map(|page| format!("{base}/users/{user_id}?tab=reports&reports_limit={limit}&reports_sent_page={}&reports_received_page={}", if kind == "sent" {page} else {sent_page}, if kind == "received" {page} else {received_page})), true))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tagged_user(
|
||||
global_name: Option<&str>,
|
||||
username: &str,
|
||||
@@ -252,7 +222,7 @@ fn type_and_category_lines(report: &ReportEntry) -> Markup {
|
||||
.zip(reason_label)
|
||||
.is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
html! {
|
||||
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
|
||||
div class="text-neutral-500 text-xs" { (report_type(report.report_type).0) }
|
||||
@if let Some(category) = &report.category {
|
||||
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
(report_category(category))
|
||||
@@ -319,7 +289,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
|
||||
(format_status(report.status))
|
||||
(report_status(report.status).0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,168 +345,3 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
html! {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1700000000000000500",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_guild_id": "1700000000000000800",
|
||||
"reported_guild_name": "Harbor"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let config = test_config();
|
||||
let sent = report_row(&config, "/admin", "sent", &report).into_string();
|
||||
assert!(
|
||||
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains(r#"data-report-webhook="1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(sent.contains("Harbor Bulletin"), "{sent}");
|
||||
assert!(!sent.contains("/admin/guilds/"), "{sent}");
|
||||
let received = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert!(!received.contains("data-report-webhook"), "{received}");
|
||||
assert!(
|
||||
received.contains(r#"href="/admin/users/1500000000000000000""#),
|
||||
"{received}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_label_the_category_and_keep_the_key() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000005",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "inappropriate_profile",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
for kind in ["sent", "received"] {
|
||||
let markup = report_row(&config, "/admin", kind, &report).into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="inappropriate_profile""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
|
||||
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-reason="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(r#"href="/admin/reports/1800000000000000005""#)
|
||||
.count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(!markup.contains(">View<"), "{markup}");
|
||||
}
|
||||
let mut unknown = report.clone();
|
||||
unknown.category = Some("future_value".to_owned());
|
||||
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
|
||||
assert!(markup.contains(">future_value<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_skip_a_reason_that_repeats_the_category() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000007",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert_eq!(
|
||||
markup.matches("Harassment or bullying").count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup.matches(r#"data-report-reason="harassment""#).count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Message<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::templates::components::pagination::pager_with_missing;
|
||||
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
config::AdminConfig,
|
||||
@@ -422,26 +424,12 @@ fn render_users_table(
|
||||
|
||||
fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> Markup {
|
||||
let next_page = params.page.checked_add(1).filter(|_| has_more);
|
||||
html! {
|
||||
div class="mt-4 flex items-center justify-between" {
|
||||
@if params.page > 0 {
|
||||
a href=(users_url(base, params, params.page - 1))
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"< Previous"
|
||||
}
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href=(users_url(base, params, next_page))
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Next >"
|
||||
}
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pager_with_missing(
|
||||
(params.page > 0).then(|| users_url(base, params, params.page - 1)),
|
||||
None,
|
||||
next_page.map(|page| users_url(base, params, page)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
|
||||
@@ -459,25 +447,3 @@ fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
|
||||
pairs.push(format!("page={page}"));
|
||||
format!("{base}/users?{}", pairs.join("&"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params() -> UserListParams {
|
||||
UserListParams::from_query(None, None, None, None, None, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_mode_has_no_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), false).into_string();
|
||||
assert!(!markup.contains("search-email"));
|
||||
assert!(markup.contains("search-q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_mode_keeps_the_email_search() {
|
||||
let markup = search_form("/admin", ¶ms(), true).into_string();
|
||||
assert!(markup.contains("search-email"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 4401 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user