mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e614026d7 | ||
|
|
b38e7c6433 | ||
|
|
83c8e91955 | ||
|
|
0532dd0440 | ||
|
|
a08615e306 | ||
|
|
f4c5fee17e | ||
|
|
c5aaf65a10 | ||
|
|
951e39da3d | ||
|
|
b693d84d2b | ||
|
|
9bbf6c513b | ||
|
|
50cec92738 | ||
|
|
c212d315f4 | ||
|
|
1861432a53 | ||
|
|
d0c6146429 | ||
|
|
550e6b05a1 | ||
|
|
5b6949170f | ||
|
|
f32bc37794 | ||
|
|
8ee2279b4b | ||
|
|
6339c3b8ad | ||
|
|
7c9274847f | ||
|
|
5d1dddc093 | ||
|
|
04481d7235 | ||
|
|
a3d6cf37cb | ||
|
|
ed10f9d323 | ||
|
|
4b278a0da8 | ||
|
|
1281045648 | ||
|
|
69c42cff90 | ||
|
|
7d56481aba | ||
|
|
91a2604e9e | ||
|
|
a9dc74a520 | ||
|
|
a9cc04d277 | ||
|
|
8cb097f954 | ||
|
|
78f783f0c4 | ||
|
|
d14998ff69 | ||
|
|
ca7ddd9272 | ||
|
|
84dcf6b8a8 | ||
|
|
a59b80ce11 | ||
|
|
007f338823 | ||
|
|
7d34d25497 | ||
|
|
7375ac9d80 | ||
|
|
6af33c7188 | ||
|
|
33737e0f79 | ||
|
|
5afbf67a70 | ||
|
|
580401d2dc | ||
|
|
38b7c63431 | ||
|
|
57d08cd091 | ||
|
|
91e2d31614 | ||
|
|
d375dc7946 | ||
|
|
3fffce2a4a | ||
|
|
376c509083 | ||
|
|
156315fd5a | ||
|
|
c7b9e9b9bd | ||
|
|
12397032e3 | ||
|
|
4a285cbb11 | ||
|
|
6d600990fe | ||
|
|
e1bc6c2f7e | ||
|
|
3e79530389 | ||
|
|
d0c84b3d9b | ||
|
|
3affd295e8 | ||
|
|
7b15e5be0f | ||
|
|
adab646d1e | ||
|
|
de1fd95a99 | ||
|
|
4bc5593f9f | ||
|
|
172791316b | ||
|
|
b30a4f5d14 | ||
|
|
f254ed679b | ||
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 | ||
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 |
@@ -14,7 +14,7 @@
|
||||
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
||||
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
|
||||
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3020, 8333],
|
||||
"portsAttributes": {
|
||||
"8088": {
|
||||
"label": "Fluxer dev proxy",
|
||||
|
||||
@@ -256,7 +256,6 @@ services:
|
||||
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
|
||||
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
|
||||
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
|
||||
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
||||
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||
depends_on:
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**/.git/**
|
||||
/.github/
|
||||
/.pnpm-store/
|
||||
/fluxer_marketing
|
||||
|
||||
**/.env
|
||||
**/.env.*.local
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
/.github/CODEOWNERS @fluxerapp/developers
|
||||
/.github/workflows/ @fluxerapp/developers
|
||||
/fluxer_marketing @fluxerapp/developers
|
||||
/.gitmodules @fluxerapp/developers
|
||||
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
|
||||
/packages/i18n/marketing/ @fluxerapp/developers
|
||||
/scripts/setup-private-marketing.sh @fluxerapp/developers
|
||||
|
||||
@@ -89,21 +89,3 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
|
||||
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
|
||||
|
||||
Authorized maintainers can initialize only that submodule and install its independent dependencies:
|
||||
|
||||
```sh
|
||||
./scripts/setup-private-marketing.sh
|
||||
pnpm --dir fluxer_marketing install --frozen-lockfile
|
||||
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
|
||||
```
|
||||
|
||||
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
|
||||
|
||||
```sh
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
```
|
||||
|
||||
@@ -22,17 +22,15 @@ f:docs:
|
||||
f:gateway:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_gateway/**/*
|
||||
f:marketing:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- fluxer_marketing
|
||||
- packages/i18n/marketing/**/*
|
||||
f:media_proxy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
||||
f:messages:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_messages/**/*
|
||||
f:recon:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_recon/**/*
|
||||
f:snowflakes:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_snowflakes/**/*
|
||||
|
||||
@@ -525,6 +525,7 @@ jobs:
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -602,7 +603,9 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -660,3 +663,8 @@ jobs:
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build recon
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-recon
|
||||
dockerfile: fluxer_recon/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
@@ -1,230 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: Dispatch private marketing build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_marketing
|
||||
- Cargo.toml
|
||||
- fluxer_common/**
|
||||
- packages/fonts/manifest.json
|
||||
- packages/fonts/NOTICE.md
|
||||
- packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
- packages/fonts/css/locale-fallbacks.css
|
||||
- packages/fonts/files/FluxerSans/**
|
||||
- packages/fonts/files/FluxerMono/**
|
||||
- packages/fonts/marketing/**
|
||||
- packages/i18n/marketing/**
|
||||
- fluxer_static/marketing/branding/**
|
||||
- .github/workflows/dispatch-private-marketing-build.yaml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: private-marketing-dispatch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
|
||||
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
|
||||
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
|
||||
(( 10#$RUN_ATTEMPT <= 10 ))
|
||||
|
||||
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
||||
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
|
||||
main_status="$(jq -r .status <<<"$main_comparison")"
|
||||
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
|
||||
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
|
||||
|
||||
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
|
||||
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
|
||||
tree_sha="$(jq -r .tree.sha <<<"$commit")"
|
||||
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
entry="$(
|
||||
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
|
||||
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
|
||||
)"
|
||||
mode="$(jq -r .mode <<<"$entry")"
|
||||
type="$(jq -r .type <<<"$entry")"
|
||||
gitlink_sha="$(jq -r .sha <<<"$entry")"
|
||||
path="$(jq -r .path <<<"$entry")"
|
||||
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
|
||||
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
|
||||
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
|
||||
[[ "$(jq -r .event <<<"$run")" == "push" ]]
|
||||
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
|
||||
run_created_at="$(jq -r .created_at <<<"$run")"
|
||||
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
|
||||
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
|
||||
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
|
||||
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
|
||||
month="$((10#$month))"
|
||||
micro="$((10#$time_segment))"
|
||||
build_version="$year.$month$day.$micro"
|
||||
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
|
||||
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
{
|
||||
echo "parent_sha=$PARENT_SHA"
|
||||
echo "gitlink_sha=$gitlink_sha"
|
||||
echo "build_version=$build_version"
|
||||
echo "correlation_id=$correlation_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
dispatch:
|
||||
name: dispatch exact private build
|
||||
needs: metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 65
|
||||
environment: private-marketing-dispatch
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Validate trusted build inputs
|
||||
env:
|
||||
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
|
||||
EXPECTED_PARENT_SHA: ${{ github.sha }}
|
||||
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
|
||||
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
|
||||
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create private dispatch token
|
||||
id: private-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: marketing
|
||||
permission-actions: write
|
||||
|
||||
- name: Dispatch exact private build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
|
||||
--field ref=main \
|
||||
--field "inputs[parent_sha]=$PARENT_SHA" \
|
||||
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
|
||||
--field "inputs[build_version]=$BUILD_VERSION" \
|
||||
--field "inputs[correlation_id]=$CORRELATION_ID"
|
||||
|
||||
- name: Wait for private build conclusion
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
|
||||
deadline=$((SECONDS + 3600))
|
||||
run_id=""
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
count="$(jq 'length' <<<"$matches")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
run_id="$(jq -r '.[0].id' <<<"$matches")"
|
||||
break
|
||||
fi
|
||||
if [[ "$count" != "0" ]]; then
|
||||
echo "::error::Private build correlation matched multiple workflow runs."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::Timed out waiting for the private build dispatch to appear."
|
||||
exit 1
|
||||
fi
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
|
||||
echo "::error::Private build correlation is missing or ambiguous."
|
||||
exit 1
|
||||
fi
|
||||
run="$(jq '.[0]' <<<"$matches")"
|
||||
status="$(jq -r '.status' <<<"$run")"
|
||||
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
echo "::error::Private marketing build concluded with $conclusion."
|
||||
exit 1
|
||||
fi
|
||||
echo "Private marketing build completed successfully."
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::Timed out waiting for the private marketing build."
|
||||
exit 1
|
||||
@@ -77,14 +77,14 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No generated catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "i18n: compile Weblate catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push origin "HEAD:$WEBLATE_BRANCH"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[submodule "fluxer_marketing"]
|
||||
path = fluxer_marketing
|
||||
url = https://github.com/fluxerapp/marketing.git
|
||||
update = none
|
||||
Generated
+37
-13
@@ -266,9 +266,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.3"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -276,14 +276,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.40.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1622,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1815,6 +1816,7 @@ dependencies = [
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"libc",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
@@ -1866,6 +1868,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-recon"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"hmac 0.13.0",
|
||||
"reqwest",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-snowflakes"
|
||||
version = "0.1.0"
|
||||
@@ -1940,6 +1961,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
@@ -1991,6 +2013,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
@@ -2019,6 +2042,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
@@ -3965,14 +3989,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -4024,7 +4048,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4035,9 +4059,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -4648,7 +4672,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5516,7 +5540,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -15,9 +15,9 @@ members = [
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
"fluxer_recon",
|
||||
]
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
Vendored
+5
-5
@@ -65,10 +65,14 @@ FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
|
||||
|
||||
FLUXER_RECON_MODE=observing
|
||||
FLUXER_RECON_EXPECTED_ROOMS=64
|
||||
FLUXER_RECON_WARMUP_SECONDS=15
|
||||
FLUXER_RECON_MAX_HOT_ROOMS=8
|
||||
|
||||
FLUXER_API_PORT=8080
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
||||
FLUXER_API_WORKER_MODE=all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
|
||||
FLUXER_APP_DEV_PORT=3000
|
||||
FLUXER_APP_PROXY_PORT=8773
|
||||
FLUXER_STATIC_DIR=fluxer_app/dist
|
||||
@@ -91,10 +95,6 @@ FLUXER_ADMIN_BASE_PATH=/admin
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
FLUXER_MARKETING_PORT=3010
|
||||
FLUXER_MARKETING_HOST=0.0.0.0
|
||||
FLUXER_MARKETING_BASE_PATH=/marketing
|
||||
FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
# Every variable docker-compose.yml reads from this file is named here:
|
||||
# uncommented when it has no default, commented with its default when it has one.
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# service only through a Compose override file that adds it to that service's
|
||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
||||
# a Compose edit forgets the matching line here. Compose expands this file from
|
||||
# top to bottom, so a line written with ${...} has to sit below every name it
|
||||
# reads.
|
||||
# Every variable docker-compose.yml reads is named here, uncommented when it has
|
||||
# no default and commented with its default when it has one. A name absent from
|
||||
# this file reaches a service only through a Compose override. Compose expands
|
||||
# top to bottom, so a line using ${...} must sit below every name it reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The three lines above are the address browsers use, and every endpoint the
|
||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
||||
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
|
||||
# do that, and a non-default port needs the matching one set as well. Both
|
||||
# complete recipes are written out beside them.
|
||||
# The lines above are the address browsers use, and every advertised endpoint
|
||||
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
|
||||
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
|
||||
# needs the matching one set as well. Complete recipes sit beside them.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
# Point DNS at this host and there is nothing else to configure.
|
||||
# Point DNS at this host.
|
||||
#
|
||||
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
|
||||
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
|
||||
@@ -65,13 +60,12 @@ FLUXER_PUBLIC_PORT=443
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
||||
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
|
||||
# unset, so an existing .env keeps the listener it already had.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# Host side of the edge's publishes, and the only two names that decide which
|
||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# Host side of the edge's publishes, and the only names that decide which host
|
||||
# ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
@@ -251,7 +245,7 @@ LIVEKIT_API_SECRET=CHANGE_ME
|
||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
|
||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
|
||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
|
||||
# server to keep the lookup and leave Google out of it.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
@@ -279,11 +273,11 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from the services whose death takes the whole instance down. They do
|
||||
# not reserve anything. Lower the limits on a smaller host.
|
||||
# The reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from services whose death takes the instance down. They reserve
|
||||
# nothing. Lower the limits on a smaller host.
|
||||
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
@@ -364,11 +358,17 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
# In-flight request ceiling for the services Compose forwards it to: the users
|
||||
# and messages routers and their shards. Leave it unset and each service uses its
|
||||
# built-in default. Set it and the one value replaces that default on all of
|
||||
# them, so size it for the busiest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them. The api
|
||||
# turns that rejection into a 503 and logs "shard rejected the request because
|
||||
# it is at its concurrency limit".
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
|
||||
@@ -565,7 +565,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -583,7 +583,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -633,7 +633,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -651,7 +651,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
+389
-1
@@ -35,11 +35,19 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
}
|
||||
|
||||
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
|
||||
let spec: openapiv3::OpenAPI =
|
||||
let mut spec: openapiv3::OpenAPI =
|
||||
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
relax_guild_audit_log_schemas(&mut spec);
|
||||
relax_progenitor_schema_strictness(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
settings.with_inner_type(
|
||||
"reqwest::header::HeaderMap"
|
||||
.parse()
|
||||
.expect("valid generated client header type"),
|
||||
);
|
||||
|
||||
let mut generator = progenitor::Generator::new(&settings);
|
||||
let tokens = generator
|
||||
@@ -54,6 +62,386 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
fs::write(&output_path, content).expect("failed to write generated API code");
|
||||
}
|
||||
|
||||
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
|
||||
let schemas = &spec
|
||||
.components
|
||||
.as_ref()
|
||||
.expect("missing API components")
|
||||
.schemas;
|
||||
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
|
||||
.expect("failed to inspect Error schema");
|
||||
let mut throttled = serde_json::to_value(
|
||||
schemas
|
||||
.get("ThrottledError")
|
||||
.expect("missing ThrottledError schema"),
|
||||
)
|
||||
.expect("failed to inspect ThrottledError schema");
|
||||
assert_eq!(
|
||||
error["additionalProperties"],
|
||||
serde_json::json!({}),
|
||||
"Progenitor error adaptation requires Error to retain all additional fields"
|
||||
);
|
||||
let properties = throttled["properties"]
|
||||
.as_object_mut()
|
||||
.expect("ThrottledError must be an object schema");
|
||||
assert_eq!(
|
||||
properties
|
||||
.remove("retry_after")
|
||||
.expect("missing retry_after")["type"],
|
||||
"number"
|
||||
);
|
||||
assert_eq!(
|
||||
properties.remove("global").expect("missing global")["type"],
|
||||
"boolean"
|
||||
);
|
||||
assert_eq!(
|
||||
throttled, error,
|
||||
"ThrottledError must extend the common Error schema"
|
||||
);
|
||||
|
||||
for path in spec.paths.paths.values_mut() {
|
||||
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||
panic!("Progenitor error adaptation requires inline API paths");
|
||||
};
|
||||
for operation in [
|
||||
&mut path.get,
|
||||
&mut path.put,
|
||||
&mut path.post,
|
||||
&mut path.delete,
|
||||
&mut path.options,
|
||||
&mut path.head,
|
||||
&mut path.patch,
|
||||
&mut path.trace,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(response) = operation
|
||||
.responses
|
||||
.responses
|
||||
.get_mut(&openapiv3::StatusCode::Code(429))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let openapiv3::ReferenceOr::Item(response) = response else {
|
||||
panic!("Progenitor error adaptation requires inline 429 responses");
|
||||
};
|
||||
let schema = &mut response
|
||||
.content
|
||||
.get_mut("application/json")
|
||||
.expect("429 responses must return JSON")
|
||||
.schema;
|
||||
assert_eq!(
|
||||
schema,
|
||||
&Some(openapiv3::ReferenceOr::ref_(
|
||||
"#/components/schemas/ThrottledError"
|
||||
)),
|
||||
"Progenitor only supports one error type per operation"
|
||||
);
|
||||
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
|
||||
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
|
||||
entry.additional_properties = None;
|
||||
let openapiv3::ReferenceOr::Item(options) = entry
|
||||
.properties
|
||||
.get_mut("options")
|
||||
.expect("GuildAuditLogEntryResponse has no options property")
|
||||
else {
|
||||
panic!("GuildAuditLogEntryResponse options must be an inline schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
|
||||
else {
|
||||
panic!("GuildAuditLogEntryResponse options must be an object schema");
|
||||
};
|
||||
options.additional_properties = None;
|
||||
|
||||
let change = object_schema_mut(components, "AuditLogChangeSchema");
|
||||
change.additional_properties = None;
|
||||
for property in ["old_value", "new_value"] {
|
||||
change.properties.insert(
|
||||
property.to_string(),
|
||||
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
|
||||
schema_data: openapiv3::SchemaData::default(),
|
||||
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema_mut<'a>(
|
||||
components: &'a mut openapiv3::Components,
|
||||
name: &str,
|
||||
) -> &'a mut openapiv3::ObjectType {
|
||||
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
|
||||
panic!("missing inline {name} schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
|
||||
else {
|
||||
panic!("{name} must be an object schema");
|
||||
};
|
||||
object
|
||||
}
|
||||
|
||||
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
|
||||
|
||||
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
|
||||
let registry = spec.components.clone().unwrap_or_default();
|
||||
|
||||
if let Some(components) = spec.components.as_mut() {
|
||||
for schema in components.schemas.values_mut() {
|
||||
relax_schema_reference(schema, ®istry);
|
||||
}
|
||||
for response in components.responses.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
for parameter in components.parameters.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
for request_body in components.request_bodies.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
}
|
||||
for header in components.headers.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||
relax_parameter_format(&mut header.format, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for path in spec.paths.paths.values_mut() {
|
||||
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||
continue;
|
||||
};
|
||||
for parameter in &mut path.parameters {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
for operation in [
|
||||
&mut path.get,
|
||||
&mut path.put,
|
||||
&mut path.post,
|
||||
&mut path.delete,
|
||||
&mut path.options,
|
||||
&mut path.head,
|
||||
&mut path.patch,
|
||||
&mut path.trace,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for parameter in &mut operation.parameters {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
|
||||
operation.request_body.as_mut()
|
||||
{
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
for response in operation
|
||||
.responses
|
||||
.responses
|
||||
.values_mut()
|
||||
.chain(operation.responses.default.iter_mut())
|
||||
{
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
|
||||
relax_content(&mut response.content, registry);
|
||||
for header in response.headers.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||
relax_parameter_format(&mut header.format, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
|
||||
for media_type in content.values_mut() {
|
||||
if let Some(schema) = media_type.schema.as_mut() {
|
||||
relax_schema_reference(schema, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
|
||||
let format = match parameter {
|
||||
openapiv3::Parameter::Query { parameter_data, .. }
|
||||
| openapiv3::Parameter::Header { parameter_data, .. }
|
||||
| openapiv3::Parameter::Path { parameter_data, .. }
|
||||
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
|
||||
};
|
||||
relax_parameter_format(format, registry);
|
||||
}
|
||||
|
||||
fn relax_parameter_format(
|
||||
format: &mut openapiv3::ParameterSchemaOrContent,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
match format {
|
||||
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
|
||||
relax_schema_reference(schema, registry)
|
||||
}
|
||||
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_schema_reference(
|
||||
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||
relax_schema(schema, registry);
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_boxed_schema_reference(
|
||||
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||
relax_schema(schema, registry);
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
|
||||
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
|
||||
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
|
||||
return;
|
||||
}
|
||||
match &mut schema.schema_kind {
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
|
||||
relax_additional_properties(&mut object.additional_properties, registry);
|
||||
for property in object.properties.values_mut() {
|
||||
relax_boxed_schema_reference(property, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
|
||||
if let Some(items) = array.items.as_mut() {
|
||||
relax_boxed_schema_reference(items, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Type(_) => {}
|
||||
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
|
||||
for subschema in subschemas {
|
||||
relax_schema_reference(subschema, registry);
|
||||
}
|
||||
}
|
||||
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
|
||||
openapiv3::SchemaKind::Any(any) => {
|
||||
relax_additional_properties(&mut any.additional_properties, registry);
|
||||
for property in any.properties.values_mut() {
|
||||
relax_boxed_schema_reference(property, registry);
|
||||
}
|
||||
if let Some(items) = any.items.as_mut() {
|
||||
relax_boxed_schema_reference(items, registry);
|
||||
}
|
||||
for subschema in any
|
||||
.one_of
|
||||
.iter_mut()
|
||||
.chain(any.all_of.iter_mut())
|
||||
.chain(any.any_of.iter_mut())
|
||||
{
|
||||
relax_schema_reference(subschema, registry);
|
||||
}
|
||||
if let Some(not) = any.not.as_mut() {
|
||||
relax_schema_reference(not, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relax_additional_properties(
|
||||
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
|
||||
registry: &openapiv3::Components,
|
||||
) {
|
||||
match additional_properties {
|
||||
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
|
||||
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
|
||||
relax_schema_reference(schema, registry)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn flattens_objects_beside_scalars(
|
||||
schema_kind: &openapiv3::SchemaKind,
|
||||
registry: &openapiv3::Components,
|
||||
) -> bool {
|
||||
let subschemas = match schema_kind {
|
||||
openapiv3::SchemaKind::OneOf { one_of } => one_of,
|
||||
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
|
||||
_ => return false,
|
||||
};
|
||||
let mut objects = false;
|
||||
let mut scalars = false;
|
||||
for subschema in subschemas {
|
||||
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
|
||||
objects = true;
|
||||
} else {
|
||||
scalars = true;
|
||||
}
|
||||
}
|
||||
objects && scalars
|
||||
}
|
||||
|
||||
fn resolves_to_object(
|
||||
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||
registry: &openapiv3::Components,
|
||||
depth: usize,
|
||||
) -> bool {
|
||||
let Some(depth) = depth.checked_sub(1) else {
|
||||
return false;
|
||||
};
|
||||
let schema = match schema {
|
||||
openapiv3::ReferenceOr::Reference { reference } => {
|
||||
let Some(target) = reference
|
||||
.strip_prefix("#/components/schemas/")
|
||||
.and_then(|name| registry.schemas.get(name))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
return resolves_to_object(target, registry, depth);
|
||||
}
|
||||
openapiv3::ReferenceOr::Item(schema) => schema,
|
||||
};
|
||||
match &schema.schema_kind {
|
||||
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
|
||||
openapiv3::SchemaKind::Type(_) => false,
|
||||
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
|
||||
.iter()
|
||||
.any(|subschema| resolves_to_object(subschema, registry, depth)),
|
||||
openapiv3::SchemaKind::Not { .. } => false,
|
||||
openapiv3::SchemaKind::Any(any) => {
|
||||
any.typ.as_deref() == Some("object")
|
||||
|| !any.properties.is_empty()
|
||||
|| any.additional_properties.is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
|
||||
+6815
-5159
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
|
||||
@@ -35,7 +35,7 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
||||
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
|
||||
let key_id = snowflake(key_id);
|
||||
self.generated()
|
||||
.delete_admin_api_key(&key_id)
|
||||
.await
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
|
||||
@@ -12,7 +12,7 @@ impl AdminApiClient {
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -28,7 +28,7 @@ impl AdminApiClient {
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -78,15 +78,17 @@ impl AdminApiClient {
|
||||
subject_id: &str,
|
||||
archive_id: &str,
|
||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_archive_download(subject_type, subject_id, archive_id)
|
||||
.get_admin_archive_download(
|
||||
subject_type,
|
||||
&snowflake(subject_id),
|
||||
&snowflake(archive_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
|
||||
@@ -13,10 +13,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
||||
let response = self
|
||||
.generated()
|
||||
.purge_admin_guild_assets(
|
||||
&generated_types::SnowflakeType::from(guild_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.purge_admin_guild_assets(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
#[cfg(test)]
|
||||
use super::types::AuditLogEntry;
|
||||
use super::types::AuditLogsListResponse;
|
||||
|
||||
pub struct SearchAuditLogsParams {
|
||||
@@ -15,7 +13,7 @@ pub struct SearchAuditLogsParams {
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -38,67 +36,25 @@ impl AdminApiClient {
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
(
|
||||
"q",
|
||||
nonempty_string(params.query.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("q", params.query.as_deref().unwrap_or_default()),
|
||||
(
|
||||
"admin_user_id",
|
||||
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
|
||||
params.admin_user_id.as_deref().unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_type",
|
||||
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
|
||||
params.target_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by.unwrap_or_default()),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("limit", limit),
|
||||
("offset", offset),
|
||||
("target_id", params.target_id.as_deref().unwrap_or_default()),
|
||||
("sort_by", sort_by.as_deref().unwrap_or_default()),
|
||||
("sort_order", sort_order.as_deref().unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
let query_params: Vec<(&str, &str)> = query_params
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_logs_response(
|
||||
response: generated_types::AuditLogsListResponseSchema,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
Ok(AuditLogsListResponse {
|
||||
logs: response.logs.into_iter().map(audit_log_entry).collect(),
|
||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||
.map_err(ApiError::Parse)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
|
||||
AuditLogEntry {
|
||||
log_id: String::from(entry.log_id),
|
||||
admin_user_id: String::from(entry.admin_user_id),
|
||||
admin_user: None,
|
||||
action: entry.action,
|
||||
target_id: entry.target_id,
|
||||
target_type: entry.target_type,
|
||||
target_user: None,
|
||||
target_guild: None,
|
||||
target_channel: None,
|
||||
related_users: Default::default(),
|
||||
related_guilds: Default::default(),
|
||||
related_channels: Default::default(),
|
||||
audit_log_reason: entry.audit_log_reason,
|
||||
metadata: entry.metadata,
|
||||
created_at: entry.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
@@ -113,12 +69,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -135,10 +85,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_lossy_audit_totals() {
|
||||
let response = generated_types::AuditLogsListResponseSchema {
|
||||
logs: Vec::new(),
|
||||
total: 1.5,
|
||||
};
|
||||
assert!(audit_logs_response(response).is_err());
|
||||
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||
let response = serde_json::json!({"logs": [], "total": total});
|
||||
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_audit_fields_without_losing_generated_string_values() {
|
||||
let json = serde_json::json!({
|
||||
"logs": [{
|
||||
"log_id": "123456789012345678",
|
||||
"admin_user_id": "234567890123456789",
|
||||
"admin_user": null,
|
||||
"action": "USER_UPDATE",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"target_user": null,
|
||||
"target_guild": null,
|
||||
"target_channel": null,
|
||||
"related_users": {},
|
||||
"related_guilds": {},
|
||||
"related_channels": {},
|
||||
"audit_log_reason": "Account review",
|
||||
"metadata": {"field": "username"},
|
||||
"created_at": "2026-09-11T12:00:00.000Z"
|
||||
}],
|
||||
"total": 1
|
||||
});
|
||||
let generated: generated_types::AuditLogsListResponseSchema =
|
||||
serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
|
||||
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), json);
|
||||
}
|
||||
}
|
||||
|
||||
+167
-97
@@ -1,56 +1,77 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"email",
|
||||
generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_1: Some(generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("email", email, None).await
|
||||
pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("email", email, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("ip", ip, None).await
|
||||
pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("ip", ip, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
|
||||
self.check_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
pub async fn add_suspicious_email_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||
suspicious_email_domain_request(domain)?.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_2: Some(suspicious_email_domain_request(domain)?),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
pub async fn remove_suspicious_email_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -59,66 +80,90 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"phrase",
|
||||
generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("phrase", phrase, None).await
|
||||
pub async fn unban_phrase(
|
||||
&self,
|
||||
phrase: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
|
||||
self.check_blocklist_entry("phrase", phrase, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
|
||||
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url",
|
||||
generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_4: Some(generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("url", url, None).await
|
||||
pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("url", url, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
|
||||
self.check_blocklist_entry("url", url, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
|
||||
pub async fn ban_url_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
match_subdomains: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url-domain",
|
||||
generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("url-domain", domain, None)
|
||||
pub async fn unban_url_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -131,19 +176,19 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
self.create_blocklist_entry(
|
||||
"file-sha",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_6: Some(generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
self.post_void_with_reason(
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -154,12 +199,8 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(
|
||||
&blocklist_entry_path("file-sha", sha256_hex),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
self.delete_blocklist_entry("file-sha", sha256_hex, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
|
||||
@@ -183,24 +224,35 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
pub async fn ban_avatar_hash(
|
||||
&self,
|
||||
hash_short: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"avatar-hash",
|
||||
generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_7: Some(generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("avatar-hash", hash_short, None)
|
||||
pub async fn unban_avatar_hash(
|
||||
&self,
|
||||
hash_short: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -213,26 +265,42 @@ impl AdminApiClient {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_admin_user_avatar(
|
||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.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, substring: &str) -> ApiResult<()> {
|
||||
pub async fn ban_profile_substring(
|
||||
&self,
|
||||
scope: &str,
|
||||
substring: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
profile_substring_request(scope, substring)?.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_8: Some(profile_substring_request(scope, substring)?),
|
||||
..Default::default()
|
||||
},
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
||||
.await
|
||||
pub async fn unban_profile_substring(
|
||||
&self,
|
||||
scope: &str,
|
||||
substring: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
substring,
|
||||
Some(scope),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_profile_substring_ban(
|
||||
@@ -248,12 +316,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
list_type: &str,
|
||||
body: generated_types::AdminBlocklistEntryCreateRequest,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.generated()
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
self.generated_with_reason(audit_log_reason)?
|
||||
.create_admin_blocklist_entry(list_type, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
.map(drop)
|
||||
.map_err(|error| self.generated_error(error))
|
||||
}
|
||||
|
||||
async fn delete_blocklist_entry(
|
||||
@@ -261,13 +331,15 @@ impl AdminApiClient {
|
||||
list_type: &str,
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
||||
self.generated()
|
||||
self.generated_with_reason(audit_log_reason)?
|
||||
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
.map(drop)
|
||||
.map_err(|error| self.generated_error(error))
|
||||
}
|
||||
|
||||
async fn check_blocklist_entry(
|
||||
@@ -276,6 +348,7 @@ impl AdminApiClient {
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_get_scope).transpose()?;
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -290,12 +363,9 @@ const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
|
||||
format!(
|
||||
"/admin/blocklists/{}/entries/{}",
|
||||
urlencoding::encode(list_type),
|
||||
urlencoding::encode(entry_value)
|
||||
)
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::BulkJobResponse;
|
||||
@@ -13,15 +13,11 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
task:
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -33,14 +29,11 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -52,14 +45,11 @@ impl AdminApiClient {
|
||||
remove_features: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -70,14 +60,10 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
task:
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -87,13 +73,9 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
|
||||
task:
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -106,37 +88,28 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect()
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
|
||||
+215
-131
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::GeneratedClient;
|
||||
use crate::{config::AdminConfig, session::Session};
|
||||
use progenitor_client::ClientInfo;
|
||||
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
|
||||
use reqwest::{Method, RequestBuilder};
|
||||
use serde::Serialize;
|
||||
@@ -34,46 +36,39 @@ impl<T> ApiResultExt<T> for ApiResult<T> {
|
||||
}
|
||||
|
||||
pub struct AdminApiClient {
|
||||
http_client: reqwest::Client,
|
||||
generated: crate::api::generated::GeneratedClient,
|
||||
base_url: String,
|
||||
access_token: String,
|
||||
proxy_client_ip_headers: HeaderMap,
|
||||
generated: GeneratedClient,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
|
||||
let generated_http_client = build_generated_http_client(config, session);
|
||||
let generated = crate::api::generated::GeneratedClient::new_with_client(
|
||||
let generated = GeneratedClient::new_with_client(
|
||||
&config.api_endpoint,
|
||||
generated_http_client,
|
||||
http_client.clone(),
|
||||
build_session_headers(config, session),
|
||||
);
|
||||
Self {
|
||||
http_client: http_client.clone(),
|
||||
generated,
|
||||
base_url: config.api_endpoint.clone(),
|
||||
access_token: session.access_token.clone(),
|
||||
proxy_client_ip_headers: build_proxy_client_ip_headers(config),
|
||||
}
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||||
let base = format!("{}{}", self.base_url, path);
|
||||
match query_params {
|
||||
None => base,
|
||||
Some(params) => {
|
||||
let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect();
|
||||
if filtered.is_empty() {
|
||||
return base;
|
||||
}
|
||||
let query = filtered
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("{base}?{query}")
|
||||
}
|
||||
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||||
let query = query_params
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
if !query.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(&query);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn request(
|
||||
@@ -81,30 +76,26 @@ impl AdminApiClient {
|
||||
method: Method,
|
||||
path: &str,
|
||||
query_params: Option<&[(&str, &str)]>,
|
||||
) -> RequestBuilder {
|
||||
let url = self.build_url(path, query_params);
|
||||
self.http_client
|
||||
.request(method, &url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.headers(self.proxy_client_ip_headers.clone())
|
||||
}
|
||||
|
||||
fn with_audit_log_reason(
|
||||
builder: RequestBuilder,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> RequestBuilder {
|
||||
match audit_log_reason {
|
||||
Some(reason) => builder.header("X-Audit-Log-Reason", reason),
|
||||
None => builder,
|
||||
}
|
||||
) -> ApiResult<RequestBuilder> {
|
||||
let url = self.build_url(path, query_params);
|
||||
Ok(self
|
||||
.generated
|
||||
.client()
|
||||
.request(method, &url)
|
||||
.header("Content-Type", "application/json")
|
||||
.headers(self.headers_with_reason(audit_log_reason)?))
|
||||
}
|
||||
|
||||
fn with_json_body(builder: RequestBuilder, body: Option<&serde_json::Value>) -> RequestBuilder {
|
||||
match body {
|
||||
Some(body) => builder.json(body),
|
||||
None => builder,
|
||||
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
|
||||
let mut headers = self.generated.inner().clone();
|
||||
if let Some(reason) = audit_log_reason {
|
||||
let mut value = HeaderValue::from_str(reason)
|
||||
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert("x-audit-log-reason", value);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
|
||||
@@ -114,13 +105,29 @@ impl AdminApiClient {
|
||||
.map_err(|e| ApiError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
async fn send_json<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<reqwest::Response> {
|
||||
let builder = self.request(method, path, None, audit_log_reason)?;
|
||||
let builder = match body {
|
||||
Some(body) => builder.json(body),
|
||||
None => builder,
|
||||
};
|
||||
Self::send_request(builder).await
|
||||
}
|
||||
|
||||
pub async fn get<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query_params: Option<&[(&str, &str)]>,
|
||||
) -> ApiResult<T> {
|
||||
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
|
||||
self.parse_response(response).await
|
||||
let response =
|
||||
Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
@@ -149,10 +156,10 @@ impl AdminApiClient {
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
let response = self
|
||||
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
@@ -161,10 +168,10 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
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<()> {
|
||||
@@ -177,9 +184,9 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
@@ -197,10 +204,10 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
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>(
|
||||
@@ -213,10 +220,10 @@ impl AdminApiClient {
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
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>(
|
||||
@@ -225,10 +232,10 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
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>(
|
||||
@@ -241,10 +248,10 @@ impl AdminApiClient {
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
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(
|
||||
@@ -253,9 +260,9 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
@@ -269,9 +276,9 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
@@ -281,31 +288,42 @@ impl AdminApiClient {
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
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<()> {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
Err(ApiError::Http {
|
||||
status,
|
||||
message: text,
|
||||
})
|
||||
}
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
|
||||
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
Err(ApiError::Http { status, message })
|
||||
}
|
||||
|
||||
pub(crate) fn generated(&self) -> &GeneratedClient {
|
||||
&self.generated
|
||||
}
|
||||
|
||||
pub(crate) fn generated_with_reason(
|
||||
&self,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<GeneratedClient> {
|
||||
Ok(GeneratedClient::new_with_client(
|
||||
self.generated.baseurl(),
|
||||
self.generated.client().clone(),
|
||||
self.headers_with_reason(audit_log_reason)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
@@ -328,28 +346,16 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> ApiResult<T> {
|
||||
let status = response.status();
|
||||
if status.as_u16() == 204 {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
return Err(ApiError::Http {
|
||||
status: status.as_u16(),
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
|
||||
let response = Self::check_response_status(response).await?;
|
||||
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
String::new()
|
||||
} else {
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?
|
||||
};
|
||||
if text.is_empty() {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||
@@ -358,17 +364,14 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_generated_http_client(config: &AdminConfig, session: &Session) -> reqwest::Client {
|
||||
fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
let auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
|
||||
let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
|
||||
.expect("failed to build generated API Authorization header");
|
||||
auth_value.set_sensitive(true);
|
||||
headers.insert(AUTHORIZATION, auth_value);
|
||||
headers.extend(build_proxy_client_ip_headers(config));
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version))
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("failed to create generated API HTTP client")
|
||||
headers
|
||||
}
|
||||
|
||||
pub(crate) fn with_proxy_client_ip_header(
|
||||
@@ -418,3 +421,84 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||
axum::http::Response::builder()
|
||||
.status(status)
|
||||
.body(body)
|
||||
.expect("valid response")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_successful_json_and_empty_responses() {
|
||||
for (status, body, expected) in [
|
||||
(200, r#"{"value":1}"#, json!({"value": 1})),
|
||||
(201, "[1,2]", json!([1, 2])),
|
||||
(202, "null", Value::Null),
|
||||
(200, "", Value::Null),
|
||||
(204, "ignored body", Value::Null),
|
||||
] {
|
||||
let actual: Value = AdminApiClient::parse_response(response(status, body))
|
||||
.await
|
||||
.expect("valid response body");
|
||||
assert_eq!(actual, expected, "HTTP {status}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_responses_preserve_null_deserialization_errors() {
|
||||
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
|
||||
.expect_err("null is not a list")
|
||||
.to_string();
|
||||
for (status, body) in [(200, ""), (204, "ignored body")] {
|
||||
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
|
||||
.await
|
||||
.expect_err("missing list");
|
||||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_preserves_deserialization_errors() {
|
||||
for body in [" ", "{", "not JSON"] {
|
||||
let expected = serde_json::from_str::<Value>(body)
|
||||
.expect_err("malformed JSON")
|
||||
.to_string();
|
||||
let error = AdminApiClient::parse_response::<Value>(response(200, body))
|
||||
.await
|
||||
.expect_err("malformed response");
|
||||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn void_responses_do_not_parse_successful_bodies() {
|
||||
for status in [200, 201, 202, 204] {
|
||||
AdminApiClient::parse_void_response(response(status, "not JSON"))
|
||||
.await
|
||||
.expect("successful void response");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_and_void_responses_preserve_http_errors() {
|
||||
for status in [302, 400, 403, 404, 500] {
|
||||
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
|
||||
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
|
||||
.await
|
||||
.map(drop);
|
||||
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
|
||||
for result in [typed, empty] {
|
||||
let error = result.expect_err("unsuccessful response");
|
||||
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,20 @@ impl AdminApiClient {
|
||||
pub async fn generate_gift_codes(
|
||||
&self,
|
||||
count: u32,
|
||||
duration_type: &str,
|
||||
duration_type: generated_types::GiftCodeDurationTypeSchema,
|
||||
duration_quantity: u32,
|
||||
) -> ApiResult<CodesResponse> {
|
||||
let body = generated_types::GenerateGiftCodesRequest {
|
||||
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
|
||||
count: crate::api::generated::nonzero_u32(count, "count")
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
duration_quantity: crate::api::generated::nonzero_u32(
|
||||
duration_quantity,
|
||||
"duration_quantity",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
|
||||
duration_type,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
duration_type,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -16,8 +16,7 @@ impl AdminApiClient {
|
||||
.list_admin_discovery_applications()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(pending_discovery_application)
|
||||
.collect()
|
||||
@@ -29,8 +28,7 @@ impl AdminApiClient {
|
||||
.list_admin_discovery_listings()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(listed_guild)
|
||||
.collect()
|
||||
@@ -41,16 +39,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason: reason
|
||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
||||
},
|
||||
);
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
|
||||
reason: reason
|
||||
.map(generated_types::DiscoveryReviewReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
@@ -64,18 +59,11 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
|
||||
reason,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
||||
},
|
||||
);
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
|
||||
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
@@ -89,7 +77,7 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminRemoveRequest {
|
||||
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
#[allow(
|
||||
clippy::all,
|
||||
unused_imports,
|
||||
@@ -16,6 +19,24 @@ pub use inner::types;
|
||||
|
||||
pub use inner::Client as GeneratedClient;
|
||||
|
||||
impl ClientHooks<HeaderMap> for GeneratedClient {
|
||||
async fn pre<E>(
|
||||
&self,
|
||||
request: &mut reqwest::Request,
|
||||
_info: &OperationInfo,
|
||||
) -> Result<(), Error<E>> {
|
||||
let headers = request.headers_mut();
|
||||
for (name, value) in self.inner() {
|
||||
headers.entry(name.clone()).or_insert_with(|| value.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
|
||||
types::SnowflakeType::Variant0(value.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
|
||||
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
||||
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
|
||||
@@ -126,10 +147,10 @@ mod tests {
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
|
||||
|
||||
assert_eq!(response.total as i64, 1);
|
||||
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);
|
||||
assert_eq!(*response.guilds[0].member_count, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_emojis(&guild_id)
|
||||
@@ -20,7 +20,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
) -> ApiResult<ListGuildStickersResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_stickers(&guild_id)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
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,
|
||||
@@ -15,7 +16,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<SearchGuildsResponse> {
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
@@ -28,13 +29,8 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
resp.guild
|
||||
self.lookup_guild(guild_id)
|
||||
.await?
|
||||
.map(GuildInfo::from)
|
||||
.ok_or_else(|| super::client::ApiError::Http {
|
||||
status: 404,
|
||||
@@ -101,7 +97,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<ListGuildMembersResponse> {
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
@@ -144,8 +140,7 @@ impl AdminApiClient {
|
||||
let limit = limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.map(generated_types::Int32Type::from);
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_audit_logs(
|
||||
@@ -153,7 +148,7 @@ impl AdminApiClient {
|
||||
None,
|
||||
None,
|
||||
before.as_ref(),
|
||||
limit.as_ref(),
|
||||
limit,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -257,39 +252,29 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(guild_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_guild_id: Some(guild_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuildSettingsPatch {
|
||||
content_warning_level: Option<generated_types::ContentWarningLevel>,
|
||||
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
|
||||
content_warning_text: Option<String>,
|
||||
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
|
||||
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
|
||||
disabled_operations: Option<generated_types::GuildOperations>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevel>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevelInput>,
|
||||
nsfw: Option<bool>,
|
||||
nsfw_level: Option<generated_types::NsfwLevel>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevel>,
|
||||
nsfw_level: Option<generated_types::NsfwLevelInput>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevelInput>,
|
||||
}
|
||||
|
||||
fn search_guilds_response(
|
||||
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
owner_global_name: response.owner_global_name,
|
||||
owner_discriminator: response.owner_discriminator,
|
||||
member_count: crate::api::generated::i64_to_u64(
|
||||
i64::from(response.member_count),
|
||||
i64::from(i32::from(response.member_count)),
|
||||
"member_count",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
nsfw_level: response.nsfw_level.map(i32::from),
|
||||
nsfw: response.nsfw,
|
||||
content_warning_level: response.content_warning_level.map(i32::from),
|
||||
content_warning_text: response.content_warning_text,
|
||||
content_warning_text: response.content_warning_text.map(String::from),
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
})
|
||||
@@ -338,9 +323,9 @@ fn guild_update_response(
|
||||
Ok(GuildUpdateResponse {
|
||||
guild: GuildInfo {
|
||||
id: String::from(guild.id),
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
banner: guild.banner,
|
||||
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,
|
||||
@@ -350,11 +335,11 @@ fn guild_update_response(
|
||||
"member_count",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
features: guild.features,
|
||||
features: guild.features.into_iter().map(String::from).collect(),
|
||||
nsfw_level: guild.nsfw_level.map(i32::from),
|
||||
nsfw: guild.nsfw,
|
||||
content_warning_level: guild.content_warning_level.map(i32::from),
|
||||
content_warning_text: guild.content_warning_text,
|
||||
content_warning_text: guild.content_warning_text.map(String::from),
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
@@ -380,10 +365,6 @@ fn guild_settings_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
@@ -22,9 +24,9 @@ impl AdminApiClient {
|
||||
let max_lookback_days = params.max_lookback_days.to_string();
|
||||
let query_params = [
|
||||
("limit", limit.as_str()),
|
||||
("cursor_bucket_day", cursor_bucket_day.as_str()),
|
||||
("cursor_created_at", cursor_created_at.as_str()),
|
||||
("cursor_job_id", cursor_job_id.as_str()),
|
||||
("cursor_bucket_day", cursor_bucket_day),
|
||||
("cursor_created_at", cursor_created_at),
|
||||
("cursor_job_id", cursor_job_id),
|
||||
("max_lookback_days", max_lookback_days.as_str()),
|
||||
("status", params.status.as_deref().unwrap_or_default()),
|
||||
("task_type", params.task_type.as_deref().unwrap_or_default()),
|
||||
@@ -39,7 +41,7 @@ impl AdminApiClient {
|
||||
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_job(job_id)
|
||||
.get_admin_job(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -68,10 +70,9 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
|
||||
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
|
||||
cursor
|
||||
.and_then(|cursor| cursor.get(field))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -43,7 +43,8 @@ impl AdminApiClient {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
confirmed_viewed: true,
|
||||
filename: filename.to_owned(),
|
||||
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
message_id: snowflake(message_id),
|
||||
reporter_full_name:
|
||||
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
||||
@@ -119,7 +120,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<MessageShredStatusResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_message_shred(job_id)
|
||||
.get_admin_message_shred(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -133,13 +134,15 @@ impl AdminApiClient {
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let context_limit = context_limit.to_string();
|
||||
let filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.search_admin_messages(
|
||||
Some(&snowflake(attachment_id)),
|
||||
&snowflake(channel_id),
|
||||
Some(context_limit.as_str()),
|
||||
Some(filename),
|
||||
Some(&filename),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -195,7 +198,3 @@ impl AdminApiClient {
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
+122
-60
@@ -1,10 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SearchReportsParams<'a> {
|
||||
pub query: Option<&'a str>,
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
pub resolved_by_admin_id: Option<&'a str>,
|
||||
pub sort_by: Option<&'a str>,
|
||||
pub sort_order: Option<&'a str>,
|
||||
pub limit: u32,
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
@@ -26,7 +46,7 @@ impl AdminApiClient {
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_report(report_id)
|
||||
.get_admin_report(&snowflake(report_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -50,51 +70,55 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
query: Option<&str>,
|
||||
status: Option<i32>,
|
||||
report_type: Option<i32>,
|
||||
category: Option<&str>,
|
||||
reporter_id: Option<&str>,
|
||||
reported_user_id: Option<&str>,
|
||||
reported_guild_id: Option<&str>,
|
||||
reported_channel_id: Option<&str>,
|
||||
guild_context_id: Option<&str>,
|
||||
resolved_by_admin_id: Option<&str>,
|
||||
sort_by: Option<&str>,
|
||||
sort_order: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
params: &SearchReportsParams<'_>,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let report_type = report_type
|
||||
let status = params
|
||||
.status
|
||||
.map(report_status)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let report_type = params
|
||||
.report_type
|
||||
.map(report_type_name)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.map(report_sort_by)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
("q", query.unwrap_or_default()),
|
||||
("q", params.query.unwrap_or_default()),
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", category.unwrap_or_default()),
|
||||
("reporter_id", reporter_id.unwrap_or_default()),
|
||||
("reported_user_id", reported_user_id.unwrap_or_default()),
|
||||
("reported_guild_id", reported_guild_id.unwrap_or_default()),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_channel_id",
|
||||
reported_channel_id.unwrap_or_default(),
|
||||
params.reported_channel_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"guild_context_id",
|
||||
params.guild_context_id.unwrap_or_default(),
|
||||
),
|
||||
("guild_context_id", guild_context_id.unwrap_or_default()),
|
||||
(
|
||||
"resolved_by_admin_id",
|
||||
resolved_by_admin_id.unwrap_or_default(),
|
||||
params.resolved_by_admin_id.unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("sort_order", params.sort_order.unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
@@ -105,24 +129,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
reporter_id: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reporter_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reporter_id: Some(reporter_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -130,24 +144,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
reported_user_id: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reported_user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_user_id: Some(reported_user_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_statuses_preserve_the_closed_wire_mapping() {
|
||||
for (value, expected) in [(0, "pending"), (1, "resolved")] {
|
||||
assert_eq!(report_status(value).expect("supported status"), expected);
|
||||
}
|
||||
for value in [-1, 2] {
|
||||
assert_eq!(
|
||||
report_status(value)
|
||||
.expect_err("unknown status")
|
||||
.to_string(),
|
||||
format!("parse error: unknown report status: {value}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_types_preserve_the_closed_wire_mapping() {
|
||||
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
|
||||
assert_eq!(report_type_name(value).expect("supported type"), expected);
|
||||
}
|
||||
for value in [-1, 3] {
|
||||
assert_eq!(
|
||||
report_type_name(value)
|
||||
.expect_err("unknown type")
|
||||
.to_string(),
|
||||
format!("parse error: unknown report type: {value}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_sort_fields_accept_only_the_existing_aliases() {
|
||||
for (field, expected) in [
|
||||
("createdAt", "created_at"),
|
||||
("created_at", "created_at"),
|
||||
("reportedAt", "reported_at"),
|
||||
("reported_at", "reported_at"),
|
||||
("resolvedAt", "resolved_at"),
|
||||
("resolved_at", "resolved_at"),
|
||||
] {
|
||||
assert_eq!(
|
||||
report_sort_by(field).expect("supported sort field"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
report_sort_by("unknown")
|
||||
.expect_err("unknown sort field")
|
||||
.to_string(),
|
||||
"parse error: unknown report sort field: unknown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
|
||||
@@ -11,8 +11,11 @@ impl AdminApiClient {
|
||||
index_type: &str,
|
||||
guild_id: Option<&str>,
|
||||
) -> ApiResult<RefreshSearchIndexResponse> {
|
||||
let index_type =
|
||||
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::RefreshSearchIndexRequest {
|
||||
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
guild_id: guild_id.map(snowflake),
|
||||
user_id: None,
|
||||
};
|
||||
let response = self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
@@ -24,11 +24,7 @@ impl AdminApiClient {
|
||||
guild_ids: &[String],
|
||||
) -> ApiResult<ReloadAllGuildsResponse> {
|
||||
let body = generated_types::ReloadGuildsRequest {
|
||||
guild_ids: guild_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::SendSystemDmResponse;
|
||||
@@ -14,11 +14,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::SendSystemDmRequest {
|
||||
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_ids: user_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -6,8 +6,14 @@ pub fn deserialize_discriminator<'de, D: Deserializer<'de>>(d: D) -> Result<Stri
|
||||
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
||||
match v {
|
||||
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
|
||||
serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))),
|
||||
_ => Ok("0000".to_owned()),
|
||||
serde_json::Value::Number(n) => n
|
||||
.as_u64()
|
||||
.map(|value| format!("{value:04}"))
|
||||
.ok_or_else(|| serde::de::Error::custom("expected an unsigned integer discriminator")),
|
||||
serde_json::Value::Null => Ok("0000".to_owned()),
|
||||
_ => Err(serde::de::Error::custom(
|
||||
"expected string or unsigned integer discriminator",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +21,9 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
|
||||
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
|
||||
serde_json::Value::Number(n) => Ok(n.as_u64().unwrap_or(0)),
|
||||
serde_json::Value::Number(n) => n
|
||||
.as_u64()
|
||||
.ok_or_else(|| serde::de::Error::custom("expected an unsigned 64-bit integer")),
|
||||
serde_json::Value::Null => Ok(0),
|
||||
_ => Err(serde::de::Error::custom("expected string or number")),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
pub sso: SsoConfigResponse,
|
||||
@@ -18,6 +20,24 @@ pub struct InstanceConfigResponse {
|
||||
pub integrations: InstanceIntegrationsResponse,
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_hover_tracking: MessageHoverTrackingConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
#[serde(default)]
|
||||
pub blocked_message_groups: BlockedMessageGroupsConfigResponse,
|
||||
#[serde(default)]
|
||||
pub guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub expression_info_card: ExpressionInfoCardConfigResponse,
|
||||
#[serde(default)]
|
||||
pub guild_header_collapse: GuildHeaderCollapseConfigResponse,
|
||||
#[serde(default)]
|
||||
pub typing_indicator_rework: TypingIndicatorReworkConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -436,6 +456,391 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
pub allow_user_override: bool,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub stereo_enabled: bool,
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "voice-ns-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
suppression_strength: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stereo_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct MessageHoverTrackingConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for MessageHoverTrackingConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-hover-tracking-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageHoverTrackingConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct MessageKeyboardFocusConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for MessageKeyboardFocusConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-keyboard-focus-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageKeyboardFocusConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct BlockedMessageGroupsConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BlockedMessageGroupsConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "blocked-message-groups-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct BlockedMessageGroupsConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct GuildActivityLogPresentationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GuildActivityLogPresentationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "guild-activity-log-presentation-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct GuildActivityLogPresentationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExpressionInfoCardConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ExpressionInfoCardConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "expression-info-card-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExpressionInfoCardConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct GuildHeaderCollapseConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GuildHeaderCollapseConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "guild-header-collapse-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct GuildHeaderCollapseConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct TypingIndicatorReworkConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for TypingIndicatorReworkConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "typing-indicator-rework-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct TypingIndicatorReworkConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
pub poll_interval_seconds: u64,
|
||||
pub poll_jitter_percent: u32,
|
||||
}
|
||||
|
||||
impl Default for ExperimentDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExperimentDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval_seconds: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_jitter_percent: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceRegistrationResponse {
|
||||
pub mode: RegistrationMode,
|
||||
@@ -525,6 +930,24 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_hover_tracking: Option<MessageHoverTrackingConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_keyboard_focus: Option<MessageKeyboardFocusConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked_message_groups: Option<BlockedMessageGroupsConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_activity_log_presentation: Option<GuildActivityLogPresentationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_header_collapse: Option<GuildHeaderCollapseConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub typing_indicator_rework: Option<TypingIndicatorReworkConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -826,3 +1249,406 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub code: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let hover = serde_json::from_value::<MessageHoverTrackingConfigResponse>(json!({}))
|
||||
.expect("default message hover tracking config");
|
||||
let keyboard = serde_json::from_value::<MessageKeyboardFocusConfigResponse>(json!({}))
|
||||
.expect("default message keyboard focus config");
|
||||
let blocked = serde_json::from_value::<BlockedMessageGroupsConfigResponse>(json!({}))
|
||||
.expect("default blocked message groups config");
|
||||
let activity_log =
|
||||
serde_json::from_value::<GuildActivityLogPresentationConfigResponse>(json!({}))
|
||||
.expect("default guild activity log presentation config");
|
||||
let expression = serde_json::from_value::<ExpressionInfoCardConfigResponse>(json!({}))
|
||||
.expect("default expression info card config");
|
||||
let collapse = serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config");
|
||||
let typing = serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
|
||||
.expect("default typing indicator rework config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let hover =
|
||||
serde_json::to_value(hover).expect("serializable message hover tracking config");
|
||||
let keyboard =
|
||||
serde_json::to_value(keyboard).expect("serializable message keyboard focus config");
|
||||
let blocked =
|
||||
serde_json::to_value(blocked).expect("serializable blocked message groups config");
|
||||
let activity_log = serde_json::to_value(activity_log)
|
||||
.expect("serializable guild activity log presentation config");
|
||||
let expression =
|
||||
serde_json::to_value(expression).expect("serializable expression info card config");
|
||||
let collapse =
|
||||
serde_json::to_value(collapse).expect("serializable guild header collapse config");
|
||||
let typing =
|
||||
serde_json::to_value(typing).expect("serializable typing indicator rework config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
let generated_hover: generated_types::MessageHoverTrackingConfigResponse =
|
||||
serde_json::from_value(hover.clone())
|
||||
.expect("generated message hover tracking config contract");
|
||||
let generated_keyboard: generated_types::MessageKeyboardFocusConfigResponse =
|
||||
serde_json::from_value(keyboard.clone())
|
||||
.expect("generated message keyboard focus config contract");
|
||||
let generated_blocked: generated_types::BlockedMessageGroupsConfigResponse =
|
||||
serde_json::from_value(blocked.clone())
|
||||
.expect("generated blocked message groups config contract");
|
||||
let generated_activity_log: generated_types::GuildActivityLogPresentationConfigResponse =
|
||||
serde_json::from_value(activity_log.clone())
|
||||
.expect("generated guild activity log presentation config contract");
|
||||
let generated_expression: generated_types::ExpressionInfoCardConfigResponse =
|
||||
serde_json::from_value(expression.clone())
|
||||
.expect("generated expression info card config contract");
|
||||
let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse =
|
||||
serde_json::from_value(collapse.clone())
|
||||
.expect("generated guild header collapse config contract");
|
||||
let generated_typing: generated_types::TypingIndicatorReworkConfigResponse =
|
||||
serde_json::from_value(typing.clone())
|
||||
.expect("generated typing indicator rework config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_hover)
|
||||
.expect("serializable generated message hover tracking config"),
|
||||
hover
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_keyboard)
|
||||
.expect("serializable generated message keyboard focus config"),
|
||||
keyboard
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_blocked)
|
||||
.expect("serializable generated blocked message groups config"),
|
||||
blocked
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_activity_log)
|
||||
.expect("serializable generated guild activity log presentation config"),
|
||||
activity_log
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_expression)
|
||||
.expect("serializable generated expression info card config"),
|
||||
expression
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_collapse)
|
||||
.expect("serializable generated guild header collapse config"),
|
||||
collapse
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_typing)
|
||||
.expect("serializable generated typing indicator rework config"),
|
||||
typing
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
("MessageHoverTrackingConfigResponse", hover),
|
||||
("MessageKeyboardFocusConfigResponse", keyboard),
|
||||
("BlockedMessageGroupsConfigResponse", blocked),
|
||||
("GuildActivityLogPresentationConfigResponse", activity_log),
|
||||
("ExpressionInfoCardConfigResponse", expression),
|
||||
("GuildHeaderCollapseConfigResponse", collapse),
|
||||
("TypingIndicatorReworkConfigResponse", typing),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
assert_eq!(
|
||||
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
|
||||
"{name}.{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_client_accepts_unknown_response_fields() {
|
||||
const GENERATED_CLIENT: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/admin_api_generated.rs"));
|
||||
assert!(
|
||||
!GENERATED_CLIENT.contains("deny_unknown_fields"),
|
||||
"fluxer_admin/build.rs must clear additionalProperties so a new API field cannot \
|
||||
blank an admin page"
|
||||
);
|
||||
let mut section = serde_json::to_value(ExpressionInfoCardConfigResponse::default())
|
||||
.expect("serializable expression info card config");
|
||||
section
|
||||
.as_object_mut()
|
||||
.expect("expression info card object")
|
||||
.insert("future_knob".to_owned(), json!(7));
|
||||
serde_json::from_value::<generated_types::ExpressionInfoCardConfigResponse>(section)
|
||||
.expect("generated instance config section tolerates unknown fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_audit_log_change_accepts_scalar_and_object_values() {
|
||||
for value in [json!("old"), json!(7), json!(true), json!(null)] {
|
||||
let change = serde_json::from_value::<generated_types::AuditLogChangeSchema>(
|
||||
json!({"key": "name", "old_value": value, "new_value": {"added": [], "removed": []}}),
|
||||
)
|
||||
.expect("generated audit log change tolerates scalar values");
|
||||
assert_eq!(change.key, "name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_info_card_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = ExpressionInfoCardConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::ExpressionInfoCardConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ExpressionInfoCardConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = GuildHeaderCollapseConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::GuildHeaderCollapseConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GuildHeaderCollapseConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_response_defaults_to_the_guild_header_collapse_v1_salt() {
|
||||
let config = GuildHeaderCollapseConfigResponse::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.config_version, 0);
|
||||
assert_eq!(config.rollout_basis_points, 0);
|
||||
assert_eq!(config.rollout_salt, "guild-header-collapse-v1");
|
||||
assert!(config.included_user_ids.is_empty());
|
||||
assert!(config.excluded_user_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config")
|
||||
.rollout_salt,
|
||||
"guild-header-collapse-v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_indicator_rework_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = TypingIndicatorReworkConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::TypingIndicatorReworkConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TypingIndicatorReworkConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_indicator_rework_response_defaults_to_the_typing_indicator_rework_v1_salt() {
|
||||
let config = TypingIndicatorReworkConfigResponse::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.config_version, 0);
|
||||
assert_eq!(config.rollout_basis_points, 0);
|
||||
assert_eq!(config.rollout_salt, "typing-indicator-rework-v1");
|
||||
assert!(config.included_user_ids.is_empty());
|
||||
assert!(config.excluded_user_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
|
||||
.expect("default typing indicator rework config")
|
||||
.rollout_salt,
|
||||
"typing-indicator-rework-v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageHoverTrackingConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::MessageHoverTrackingConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageHoverTrackingConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keyboard_focus_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageKeyboardFocusConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::MessageKeyboardFocusConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageKeyboardFocusConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_message_groups_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = BlockedMessageGroupsConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::BlockedMessageGroupsConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(BlockedMessageGroupsConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_activity_log_presentation_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = GuildActivityLogPresentationConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::GuildActivityLogPresentationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GuildActivityLogPresentationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
guild_overrides: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
pub soft_connection_limit: Option<i64>,
|
||||
pub vip_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required_guild_features: Vec<String>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -15,7 +15,7 @@ impl AdminApiClient {
|
||||
email: Option<&str>,
|
||||
last_active_ip: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
offset: u64,
|
||||
) -> ApiResult<SearchUsersResponse> {
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
@@ -35,7 +35,8 @@ impl AdminApiClient {
|
||||
let response = response.into_inner();
|
||||
Ok(SearchUsersResponse {
|
||||
users: self.generated_value(response.users)?,
|
||||
total: response.total as u64,
|
||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||
.map_err(ApiError::Parse)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -362,11 +363,8 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserUsernameUpdateRequest {
|
||||
discriminator: discriminator
|
||||
.map(generated_types::DiscriminatorType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
username: generated_types::UsernameType::try_from(username)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
|
||||
username: generated_types::UsernameType::from(username.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -399,7 +397,8 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
@@ -428,21 +427,24 @@ impl AdminApiClient {
|
||||
reason_code: i32,
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.generated_with_reason(audit_log_reason)?
|
||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
.map_err(|error| self.generated_error(error))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
@@ -484,10 +486,10 @@ impl AdminApiClient {
|
||||
target_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<()> {
|
||||
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.generated()
|
||||
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
|
||||
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -498,7 +500,7 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
||||
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -565,10 +567,6 @@ fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
+108
-21
@@ -55,15 +55,14 @@ impl AdminApiClient {
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_region(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let body = voice_request_body(params, &["id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||
@@ -103,9 +102,10 @@ impl AdminApiClient {
|
||||
) -> ApiResult<CreateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
|
||||
voice_request_body(params, &["region_id"])?,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.create_admin_voice_server(®ion_id, &body)
|
||||
@@ -121,15 +121,18 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
urlencoding::encode(&server_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_server(
|
||||
@@ -150,6 +153,26 @@ 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)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn voice_request_body(
|
||||
params: &serde_json::Value,
|
||||
path_fields: &[&str],
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
let mut body = params
|
||||
.as_object()
|
||||
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
|
||||
.clone();
|
||||
for field in path_fields {
|
||||
body.remove(*field);
|
||||
}
|
||||
Ok(body.into())
|
||||
}
|
||||
|
||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
@@ -168,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn region_update_body_preserves_explicit_restriction_clears() {
|
||||
let expected = json!({
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
});
|
||||
let mut params = expected.clone();
|
||||
params["id"] = json!("eu");
|
||||
let body = voice_request_body(¶ms, &["id"]).expect("region body");
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
|
||||
.expect("valid region body");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_update_body_preserves_clears_and_omitted_restrictions() {
|
||||
for expected in [
|
||||
json!({
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"soft_connection_limit": null,
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
}),
|
||||
json!({"is_active": false}),
|
||||
] {
|
||||
let mut params = expected.clone();
|
||||
params["region_id"] = json!("eu");
|
||||
params["server_id"] = json!("primary");
|
||||
let body =
|
||||
voice_request_body(¶ms, &["region_id", "server_id"]).expect("server body");
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
|
||||
.expect("valid server body");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
|
||||
let expected = json!({
|
||||
"server_id": "primary",
|
||||
"endpoint": "wss://voice.example.com",
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
});
|
||||
let mut params = expected.clone();
|
||||
params["region_id"] = json!("eu");
|
||||
let body = voice_request_body(¶ms, &["region_id"]).expect("server body");
|
||||
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
|
||||
.expect("valid server body");
|
||||
assert_eq!(body, expected);
|
||||
assert!(
|
||||
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn admin_api_keys_post(
|
||||
"create" => {
|
||||
let name = form.clean("name").unwrap_or_default();
|
||||
let acls = form.list_values_any(&["acls[]", "acls"]);
|
||||
return match client.create_api_key(&name, &acls).await {
|
||||
match client.create_api_key(&name, &acls).await {
|
||||
Ok(created) => {
|
||||
let keys = client
|
||||
.list_api_keys()
|
||||
@@ -107,29 +107,38 @@ async fn admin_api_keys_post(
|
||||
is_htmx,
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
"revoke" => {
|
||||
if let Some(key_id) = form.clean("key_id") {
|
||||
let result = client.revoke_api_key(&key_id).await;
|
||||
let flash = match result.log_error("revoke API key") {
|
||||
Some(_) => FlashData::success("API key revoked."),
|
||||
None => FlashData::error("Failed to revoke API key"),
|
||||
};
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
"revoke" => {
|
||||
let Some(key_id) = form.clean("key_id") else {
|
||||
return admin_api_key_flash_response(
|
||||
config,
|
||||
FlashData::error("API key ID is required"),
|
||||
is_htmx,
|
||||
);
|
||||
};
|
||||
let result = client.revoke_api_key(&key_id).await;
|
||||
let flash = match result.log_error("revoke API key") {
|
||||
Some(_) => FlashData::success("API key revoked."),
|
||||
None => FlashData::error("Failed to revoke API key"),
|
||||
};
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
"" => admin_api_key_flash_response(
|
||||
config,
|
||||
FlashData::error("API key action is required"),
|
||||
is_htmx,
|
||||
),
|
||||
_ => admin_api_key_flash_response(
|
||||
config,
|
||||
FlashData::error("Unknown API key action"),
|
||||
is_htmx,
|
||||
),
|
||||
}
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
FlashData::success(format!("API key action '{action}' completed.")),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_api_key_flash_response(
|
||||
|
||||
@@ -16,7 +16,7 @@ use axum::{
|
||||
|
||||
use super::ActionQuery;
|
||||
use super::bans_actions::{
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, htmx_flash,
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -105,7 +105,7 @@ async fn generic_ban_post(
|
||||
form.audit_log_reason.as_deref(),
|
||||
)
|
||||
.await;
|
||||
flash_response(config, auth, is_htmx, &level, &msg, ban_cfg, csrf_token)
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
}
|
||||
|
||||
macro_rules! ban_post {
|
||||
@@ -171,19 +171,22 @@ async fn url_domain_bans_post(
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
||||
let form: BanFormData = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
|
||||
Err(_) => return render_inline_flash("error", "Invalid form data"),
|
||||
};
|
||||
let is_htmx = htmx::is_htmx_request(&headers);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
|
||||
if domain.is_empty() {
|
||||
return htmx_flash("error", "Domain is required", &headers);
|
||||
return render_inline_flash("error", "Domain is required");
|
||||
}
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
let (level, msg) = match action {
|
||||
"ban" => {
|
||||
let m_sub = form.match_subdomains.as_deref() == Some("true");
|
||||
match client.ban_url_domain(&domain, m_sub).await {
|
||||
match client
|
||||
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||
@@ -191,7 +194,10 @@ async fn url_domain_bans_post(
|
||||
}
|
||||
}
|
||||
}
|
||||
"unban" => match client.unban_url_domain(&domain).await {
|
||||
"unban" => match client
|
||||
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||
@@ -247,25 +253,31 @@ async fn profile_substring_bans_post(
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
||||
let form: BanFormData = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
|
||||
Err(_) => return render_inline_flash("error", "Invalid form data"),
|
||||
};
|
||||
let is_htmx = htmx::is_htmx_request(&headers);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
|
||||
let substring = form.substring.as_deref().unwrap_or("").trim().to_owned();
|
||||
if scope.is_empty() || substring.is_empty() {
|
||||
return htmx_flash("error", "Scope and substring required", &headers);
|
||||
return render_inline_flash("error", "Scope and substring required");
|
||||
}
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
let (level, msg) = match action {
|
||||
"ban" => match client.ban_profile_substring(&scope, &substring).await {
|
||||
"ban" => match client
|
||||
.ban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
|
||||
("error", format!("Failed to ban substring for {scope}"))
|
||||
}
|
||||
},
|
||||
"unban" => match client.unban_profile_substring(&scope, &substring).await {
|
||||
"unban" => match client
|
||||
.unban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::client::AdminApiClient;
|
||||
use crate::api::client::{AdminApiClient, ApiResult};
|
||||
use crate::api::types::{FlashLevel, FlashMessage};
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::templates;
|
||||
use axum::{
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct BanFormData {
|
||||
#[serde(default)]
|
||||
pub ip: Option<String>,
|
||||
@@ -65,7 +61,7 @@ pub async fn execute_ban(
|
||||
bulk_hashes: Option<&str>,
|
||||
bulk_sha256_list: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (String, String) {
|
||||
) -> (&'static str, String) {
|
||||
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
||||
let raw_hashes = if action == "bulk-ban-files" {
|
||||
bulk_sha256_list
|
||||
@@ -75,13 +71,13 @@ pub async fn execute_ban(
|
||||
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
|
||||
}
|
||||
if value.is_empty() {
|
||||
return ("error".into(), "Value is required".into());
|
||||
return ("error", "Value is required".into());
|
||||
}
|
||||
match action {
|
||||
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
||||
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
||||
"check" => execute_check(client, ban_type, value).await,
|
||||
_ => ("error".into(), "Unknown action".into()),
|
||||
_ => ("error", "Unknown action".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +85,7 @@ async fn execute_bulk_ban(
|
||||
client: &AdminApiClient,
|
||||
bulk_hashes: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (String, String) {
|
||||
) -> (&'static str, String) {
|
||||
let hashes: Vec<String> = bulk_hashes
|
||||
.unwrap_or("")
|
||||
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
|
||||
@@ -98,18 +94,15 @@ async fn execute_bulk_ban(
|
||||
.collect();
|
||||
if hashes.is_empty() {
|
||||
return (
|
||||
"error".into(),
|
||||
"error",
|
||||
"No valid 64-character hex hashes found in input".into(),
|
||||
);
|
||||
}
|
||||
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
|
||||
Ok(r) => (
|
||||
"success".into(),
|
||||
format!("Bulk ban job created: {}", r.job_id),
|
||||
),
|
||||
Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
|
||||
("error".into(), "Failed to enqueue bulk ban job".into())
|
||||
("error", "Failed to enqueue bulk ban job".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,29 +112,26 @@ async fn execute_single_ban(
|
||||
ban_type: &str,
|
||||
value: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let success = format!("{value} banned successfully");
|
||||
let failure = format!("Failed to ban {value}");
|
||||
match ban_type {
|
||||
"ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure),
|
||||
"email-bans" => ban_action_result(client.ban_email(value).await, success, failure),
|
||||
"suspicious-email-domains" => ban_action_result(
|
||||
client.add_suspicious_email_domain(value).await,
|
||||
success,
|
||||
failure,
|
||||
),
|
||||
"phrase-bans" => ban_action_result(client.ban_phrase(value).await, success, failure),
|
||||
"url-bans" => ban_action_result(client.ban_url(value).await, success, failure),
|
||||
"file-sha-bans" => ban_action_result(
|
||||
client.ban_file_sha(value, audit_log_reason).await,
|
||||
success,
|
||||
failure,
|
||||
),
|
||||
"avatar-hash-bans" => {
|
||||
ban_action_result(client.ban_avatar_hash(value).await, success, failure)
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||
"suspicious-email-domains" => {
|
||||
client
|
||||
.add_suspicious_email_domain(value, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
_ => ("error".into(), "Unknown ban type".into()),
|
||||
}
|
||||
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
|
||||
"avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await,
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
ban_action_result(
|
||||
result,
|
||||
format!("{value} banned successfully"),
|
||||
format!("Failed to ban {value}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_single_unban(
|
||||
@@ -149,32 +139,33 @@ async fn execute_single_unban(
|
||||
ban_type: &str,
|
||||
value: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let success = format!("{value} unbanned successfully");
|
||||
let failure = format!("Failed to unban {value}");
|
||||
match ban_type {
|
||||
"ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure),
|
||||
"email-bans" => ban_action_result(client.unban_email(value).await, success, failure),
|
||||
"suspicious-email-domains" => ban_action_result(
|
||||
client.remove_suspicious_email_domain(value).await,
|
||||
success,
|
||||
failure,
|
||||
),
|
||||
"phrase-bans" => ban_action_result(client.unban_phrase(value).await, success, failure),
|
||||
"url-bans" => ban_action_result(client.unban_url(value).await, success, failure),
|
||||
"file-sha-bans" => ban_action_result(
|
||||
client.unban_file_sha(value, audit_log_reason).await,
|
||||
success,
|
||||
failure,
|
||||
),
|
||||
"avatar-hash-bans" => {
|
||||
ban_action_result(client.unban_avatar_hash(value).await, success, failure)
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.unban_email(value, audit_log_reason).await,
|
||||
"suspicious-email-domains" => {
|
||||
client
|
||||
.remove_suspicious_email_domain(value, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
_ => ("error".into(), "Unknown ban type".into()),
|
||||
}
|
||||
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.unban_url(value, audit_log_reason).await,
|
||||
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
|
||||
"avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await,
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
ban_action_result(
|
||||
result,
|
||||
format!("{value} unbanned successfully"),
|
||||
format!("Failed to unban {value}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> (String, String) {
|
||||
async fn execute_check(
|
||||
client: &AdminApiClient,
|
||||
ban_type: &str,
|
||||
value: &str,
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.check_ip_ban(value).await,
|
||||
"email-bans" => client.check_email_ban(value).await,
|
||||
@@ -183,28 +174,28 @@ async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) ->
|
||||
"url-bans" => client.check_url_ban(value).await,
|
||||
"file-sha-bans" => client.check_file_sha_ban(value).await,
|
||||
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
|
||||
_ => return ("error".into(), "Unknown ban type".into()),
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => ("info".into(), format!("{value} is banned")),
|
||||
Ok(_) => ("info".into(), format!("{value} is NOT banned")),
|
||||
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
||||
("error".into(), "Error checking ban status".into())
|
||||
("error", "Error checking ban status".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_action_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
fn ban_action_result(
|
||||
result: ApiResult<()>,
|
||||
success_message: String,
|
||||
error_message: String,
|
||||
) -> (String, String) {
|
||||
) -> (&'static str, String) {
|
||||
match result {
|
||||
Ok(_) => ("success".into(), success_message),
|
||||
Ok(()) => ("success", success_message),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: ban action");
|
||||
("error".into(), error_message)
|
||||
("error", error_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,11 +219,6 @@ pub fn flash_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn htmx_flash(level: &str, message: &str, headers: &HeaderMap) -> Response {
|
||||
let _ = headers;
|
||||
render_inline_flash(level, message)
|
||||
}
|
||||
|
||||
pub fn render_inline_flash(level: &str, message: &str) -> Response {
|
||||
let (border, bg, text) = match level {
|
||||
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient,
|
||||
api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
@@ -28,11 +28,11 @@ struct GiftCodesForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
count: Option<String>,
|
||||
count: Option<u32>,
|
||||
#[serde(default)]
|
||||
duration_type: Option<String>,
|
||||
duration_type: Option<GiftCodeDurationTypeSchema>,
|
||||
#[serde(default)]
|
||||
duration_quantity: Option<String>,
|
||||
duration_quantity: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -86,21 +86,17 @@ async fn gift_codes_post(
|
||||
);
|
||||
}
|
||||
};
|
||||
let count = form
|
||||
.count
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 100);
|
||||
let dur_type = form.duration_type.as_deref().unwrap_or("month");
|
||||
let dur_qty = form
|
||||
.duration_quantity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES);
|
||||
let duration_type = form
|
||||
.duration_type
|
||||
.unwrap_or(GiftCodeDurationTypeSchema::Months);
|
||||
let duration_quantity = form.duration_quantity.unwrap_or(1);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let secure_cookies = config.secure_cookies();
|
||||
match client.generate_gift_codes(count, dur_type, dur_qty).await {
|
||||
match client
|
||||
.generate_gift_codes(count, duration_type, duration_quantity)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let codes = result.codes.join(",");
|
||||
flash::redirect_with_flash(
|
||||
|
||||
@@ -43,7 +43,7 @@ pub async fn render(
|
||||
let page = query.members_page.unwrap_or(0);
|
||||
let limit: u32 = 50;
|
||||
let resp = client
|
||||
.list_guild_members(guild_id, limit, page * limit)
|
||||
.list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit))
|
||||
.await
|
||||
.log_error("load guild members")?;
|
||||
Some(tabs::members::members_tab(
|
||||
@@ -57,7 +57,7 @@ pub async fn render(
|
||||
let page = query.reports_page.unwrap_or(0);
|
||||
let limit: u32 = 25;
|
||||
let resp = client
|
||||
.search_reports_by_guild(guild_id, limit, page * limit)
|
||||
.search_reports_by_guild(guild_id, limit, u64::from(page) * u64::from(limit))
|
||||
.await
|
||||
.log_error("load guild reports")?;
|
||||
Some(tabs::reports::reports_tab(
|
||||
|
||||
@@ -82,23 +82,19 @@ async fn guilds_list(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((guilds, Some(params.requested_ids.len() as u64), false))
|
||||
Some((guilds, params.requested_ids.len() as u64))
|
||||
} else if params.has_search() {
|
||||
let offset = params.page.saturating_mul(params.limit);
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_guilds(params.search_query(), params.limit, offset)
|
||||
.await
|
||||
.log_error("search guilds")
|
||||
.map(|response| {
|
||||
let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total;
|
||||
(response.guilds, Some(response.total), has_more)
|
||||
})
|
||||
.map(|response| (response.guilds, response.total))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result_guilds = results.as_ref().map(|result| result.0.as_slice());
|
||||
let total = results.as_ref().and_then(|result| result.1);
|
||||
let has_more = results.as_ref().is_some_and(|result| result.2);
|
||||
let total = results.as_ref().map(|result| result.1);
|
||||
|
||||
let markup = templates::pages::guilds_list::guilds_list_page(
|
||||
config,
|
||||
@@ -106,7 +102,6 @@ async fn guilds_list(
|
||||
¶ms,
|
||||
result_guilds,
|
||||
total,
|
||||
has_more,
|
||||
is_results_fragment,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
@@ -324,8 +319,12 @@ async fn dispatch_guild_action(
|
||||
"default_message_notifications",
|
||||
"disabled_operations",
|
||||
] {
|
||||
if let Some(v) = form.parse_i64(key) {
|
||||
settings.insert(key.to_string(), serde_json::json!(v));
|
||||
let value = match form.parse_value::<i64>(key) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return FlashData::error(format!("Invalid {key} value")),
|
||||
};
|
||||
if let Some(value) = value {
|
||||
settings.insert(key.to_string(), serde_json::json!(value));
|
||||
}
|
||||
}
|
||||
if form.contains_key("nsfw_submitted") {
|
||||
@@ -356,11 +355,12 @@ async fn dispatch_guild_action(
|
||||
)
|
||||
}
|
||||
"update_disabled_operations" => {
|
||||
let disabled_operations = form
|
||||
.list_values_any(&["disabled_operations[]", "disabled_operations"])
|
||||
.iter()
|
||||
.filter_map(|value| value.parse::<i64>().ok())
|
||||
.fold(0_i64, |acc, value| acc | value);
|
||||
let Ok(operations) =
|
||||
form.parse_list_values::<i64>(&["disabled_operations[]", "disabled_operations"])
|
||||
else {
|
||||
return FlashData::error("Invalid disabled operation value");
|
||||
};
|
||||
let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value);
|
||||
let settings = serde_json::json!({
|
||||
"disabled_operations": disabled_operations,
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct JobsQuery {
|
||||
status: Option<String>,
|
||||
task_type: Option<String>,
|
||||
@@ -219,49 +218,36 @@ async fn job_detail_post(
|
||||
}
|
||||
|
||||
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
|
||||
let mut params = Vec::new();
|
||||
push_query(&mut params, "status", query.status.as_deref());
|
||||
push_query(&mut params, "task_type", query.task_type.as_deref());
|
||||
push_query(
|
||||
&mut params,
|
||||
"requested_by_user_id",
|
||||
query.requested_by_user_id.as_deref(),
|
||||
);
|
||||
push_query(
|
||||
&mut params,
|
||||
"max_lookback_days",
|
||||
query.max_lookback_days.as_deref(),
|
||||
);
|
||||
push_query(
|
||||
&mut params,
|
||||
"cursor_bucket_day",
|
||||
query.cursor_bucket_day.as_deref(),
|
||||
);
|
||||
push_query(
|
||||
&mut params,
|
||||
"cursor_created_at",
|
||||
query.cursor_created_at.as_deref(),
|
||||
);
|
||||
push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref());
|
||||
if params.is_empty() {
|
||||
return format!("{}/jobs", config.base_path);
|
||||
}
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("{}/jobs?{query}", config.base_path)
|
||||
}
|
||||
|
||||
fn push_query(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
||||
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
||||
params.push((key.to_owned(), value.to_owned()));
|
||||
let query = [
|
||||
("status", query.status.as_deref()),
|
||||
("task_type", query.task_type.as_deref()),
|
||||
(
|
||||
"requested_by_user_id",
|
||||
query.requested_by_user_id.as_deref(),
|
||||
),
|
||||
("max_lookback_days", query.max_lookback_days.as_deref()),
|
||||
("cursor_bucket_day", query.cursor_bucket_day.as_deref()),
|
||||
("cursor_created_at", query.cursor_created_at.as_deref()),
|
||||
("cursor_job_id", query.cursor_job_id.as_deref()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| (key, value))
|
||||
})
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
if query.is_empty() {
|
||||
format!("{}/jobs", config.base_path)
|
||||
} else {
|
||||
format!("{}/jobs?{query}", config.base_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,53 +59,51 @@ pub(crate) async fn messages_post(
|
||||
match action {
|
||||
"lookup" => {
|
||||
let context_limit = parse_context_limit(form.first("context_limit"));
|
||||
return Redirect::to(&format!(
|
||||
Redirect::to(&format!(
|
||||
"{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
|
||||
encode_opt(&channel_id),
|
||||
encode_opt(&message_id)
|
||||
))
|
||||
.into_response();
|
||||
.into_response()
|
||||
}
|
||||
"lookup-by-attachment" => {
|
||||
let attachment_id = form.clean("attachment_id");
|
||||
let filename = form.clean("filename");
|
||||
let context_limit = parse_context_limit(form.first("context_limit"));
|
||||
return Redirect::to(&format!(
|
||||
Redirect::to(&format!(
|
||||
"{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
|
||||
encode_opt(&channel_id),
|
||||
encode_opt(&attachment_id),
|
||||
encode_opt(&filename)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
"browse" => {
|
||||
return Redirect::to(&format!(
|
||||
"{base}/messages?channel_id={}",
|
||||
encode_opt(&channel_id)
|
||||
))
|
||||
.into_response();
|
||||
.into_response()
|
||||
}
|
||||
"browse" => Redirect::to(&format!(
|
||||
"{base}/messages?channel_id={}",
|
||||
encode_opt(&channel_id)
|
||||
))
|
||||
.into_response(),
|
||||
"search" => {
|
||||
let search = form.clean("search");
|
||||
return Redirect::to(&format!(
|
||||
Redirect::to(&format!(
|
||||
"{base}/messages?channel_id={}&search={}",
|
||||
encode_opt(&channel_id),
|
||||
encode_opt(&search)
|
||||
))
|
||||
.into_response();
|
||||
.into_response()
|
||||
}
|
||||
"delete" => {
|
||||
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
|
||||
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
|
||||
};
|
||||
let audit_log_reason = form.clean("audit_log_reason");
|
||||
return match client
|
||||
match client
|
||||
.delete_message(cid, mid, audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
"report-to-ncmec" => {
|
||||
let attachment_id = form.clean("attachment_id");
|
||||
@@ -131,7 +129,7 @@ pub(crate) async fn messages_post(
|
||||
"Missing required NCMEC report fields",
|
||||
);
|
||||
}
|
||||
return match client
|
||||
match client
|
||||
.report_attachment_to_ncmec(
|
||||
cid,
|
||||
mid,
|
||||
@@ -144,11 +142,10 @@ pub(crate) async fn messages_post(
|
||||
{
|
||||
Ok(resp) => Json(resp.data).into_response(),
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => Redirect::to(&format!("{base}/messages")).into_response(),
|
||||
}
|
||||
Redirect::to(&format!("{base}/messages")).into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn system_dms_post(
|
||||
@@ -253,14 +250,22 @@ pub(crate) async fn bulk_actions_post(
|
||||
}
|
||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
|
||||
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
|
||||
let (Ok(reason_code), Ok(days)) = (
|
||||
form.parse_value::<u32>("reason_code"),
|
||||
form.parse_value::<u32>("days_until_deletion"),
|
||||
) else {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid deletion reason code or delay"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let public_reason = form.clean("public_reason");
|
||||
client
|
||||
.bulk_schedule_user_deletion(
|
||||
&user_ids,
|
||||
reason_code,
|
||||
days,
|
||||
reason_code.unwrap_or(2),
|
||||
days.unwrap_or(14),
|
||||
public_reason.as_deref(),
|
||||
audit_log_reason.as_deref(),
|
||||
)
|
||||
@@ -357,7 +362,7 @@ pub(crate) async fn messages_browse_fragment(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_context_limit(value: Option<&str>) -> u32 {
|
||||
pub(super) fn parse_context_limit(value: Option<&str>) -> u32 {
|
||||
value
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
|
||||
@@ -16,7 +16,6 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct MessagesQuery {
|
||||
channel_id: Option<String>,
|
||||
message_id: Option<String>,
|
||||
@@ -82,13 +81,7 @@ async fn messages_page(
|
||||
let before = query.before.as_deref().filter(|s| !s.is_empty());
|
||||
let after = query.after.as_deref().filter(|s| !s.is_empty());
|
||||
let search = query.search.as_deref().filter(|s| !s.is_empty());
|
||||
let context_limit = query
|
||||
.context_limit
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(50)
|
||||
.min(100);
|
||||
let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref());
|
||||
let mut lookup_result = None;
|
||||
let mut browse_result = None;
|
||||
let mut search_result = None;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
@@ -23,7 +26,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const MAX_REPORT_OFFSET: u32 = 10_000;
|
||||
const MAX_REPORT_OFFSET: u64 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReportsQuery {
|
||||
@@ -72,7 +75,7 @@ async fn reports_list(
|
||||
let config = state.config();
|
||||
let page = query.page.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(25).clamp(1, 200);
|
||||
let offset = page.saturating_mul(limit);
|
||||
let offset = u64::from(page) * u64::from(limit);
|
||||
if offset > MAX_REPORT_OFFSET {
|
||||
return reports_error_page(
|
||||
config,
|
||||
@@ -89,22 +92,22 @@ async fn reports_list(
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(
|
||||
search_query.as_deref(),
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
query.category.as_deref(),
|
||||
query.reporter_id.as_deref(),
|
||||
query.reported_user_id.as_deref(),
|
||||
query.reported_guild_id.as_deref(),
|
||||
query.reported_channel_id.as_deref(),
|
||||
query.guild_context_id.as_deref(),
|
||||
query.resolved_by_admin_id.as_deref(),
|
||||
Some(sort_by),
|
||||
Some(sort_order),
|
||||
category: query.category.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.log_error("search reports");
|
||||
|
||||
|
||||
@@ -17,14 +17,12 @@ use serde::Deserialize;
|
||||
use super::system_actions;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GatewayQuery {
|
||||
leaderboard_limit: Option<String>,
|
||||
node_stats: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct AuditLogsQuery {
|
||||
q: Option<String>,
|
||||
admin_user_id: Option<String>,
|
||||
@@ -136,7 +134,7 @@ async fn audit_logs_page(
|
||||
sort_by: Some(params.sort_by.to_owned()),
|
||||
sort_order: Some(params.sort_order.to_owned()),
|
||||
limit,
|
||||
offset: current_page * limit,
|
||||
offset: u64::from(current_page) * u64::from(limit),
|
||||
};
|
||||
let result = client
|
||||
.search_audit_logs(&search_params)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,9 @@ pub async fn dispatch(
|
||||
"Failed to update user flags",
|
||||
);
|
||||
}
|
||||
let submitted = parse_u64_list(form, &["flags[]", "flags"]);
|
||||
let Ok(submitted) = form.parse_list_values::<u64>(&["flags[]", "flags"]) else {
|
||||
return DispatchOutcome::error("Invalid user flag value");
|
||||
};
|
||||
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
||||
let user = match client.get_user_by_id(user_id).await {
|
||||
Ok(user) => user,
|
||||
@@ -89,15 +91,22 @@ pub async fn dispatch(
|
||||
}
|
||||
"update_premium_flags" => {
|
||||
if has_legacy_flag_delta_fields(form) {
|
||||
let add = parse_i32_list(form, &["add_flags[]", "add_flags"]);
|
||||
let remove = parse_i32_list(form, &["remove_flags[]", "remove_flags"]);
|
||||
let Ok(add) = form.parse_list_values::<i32>(&["add_flags[]", "add_flags"]) else {
|
||||
return DispatchOutcome::error("Invalid premium flag value to add");
|
||||
};
|
||||
let Ok(remove) = form.parse_list_values::<i32>(&["remove_flags[]", "remove_flags"])
|
||||
else {
|
||||
return DispatchOutcome::error("Invalid premium flag value to remove");
|
||||
};
|
||||
return DispatchOutcome::from_result(
|
||||
client.update_premium_flags(user_id, &add, &remove).await,
|
||||
"Premium flags updated successfully",
|
||||
"Failed to update premium flags",
|
||||
);
|
||||
}
|
||||
let submitted = parse_i32_list(form, &["flags[]", "flags"]);
|
||||
let Ok(submitted) = form.parse_list_values::<i32>(&["flags[]", "flags"]) else {
|
||||
return DispatchOutcome::error("Invalid premium flag value");
|
||||
};
|
||||
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
||||
let user = match client.get_user_by_id(user_id).await {
|
||||
Ok(user) => user,
|
||||
@@ -124,9 +133,12 @@ pub async fn dispatch(
|
||||
)
|
||||
}
|
||||
"update_suspicious_flags" => {
|
||||
let flags = parse_i32_list(form, &["suspicious_flags[]", "suspicious_flags"])
|
||||
.into_iter()
|
||||
.fold(0, |acc, flag| acc | flag);
|
||||
let Ok(submitted) =
|
||||
form.parse_list_values::<i32>(&["suspicious_flags[]", "suspicious_flags"])
|
||||
else {
|
||||
return DispatchOutcome::error("Invalid suspicious activity flag value");
|
||||
};
|
||||
let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag);
|
||||
DispatchOutcome::from_result(
|
||||
client.update_suspicious_flags(user_id, flags).await,
|
||||
"Suspicious activity flags updated successfully",
|
||||
@@ -225,15 +237,19 @@ pub async fn dispatch(
|
||||
)
|
||||
}
|
||||
"temp_ban" => {
|
||||
let dur = form
|
||||
.parse_u32("duration_hours")
|
||||
.or_else(|| form.parse_u32("duration"))
|
||||
.unwrap_or(24);
|
||||
let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
};
|
||||
let reason = get("reason");
|
||||
let private = get("private_reason");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.temp_ban_user(user_id, dur, reason.as_deref(), private.as_deref())
|
||||
.temp_ban_user(
|
||||
user_id,
|
||||
duration.unwrap_or(24),
|
||||
reason.as_deref(),
|
||||
private.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User temporarily banned successfully",
|
||||
"Failed to temporarily ban user",
|
||||
@@ -249,7 +265,7 @@ pub async fn dispatch(
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_ip(&ip).await,
|
||||
client.ban_ip(&ip, None).await,
|
||||
"IP banned successfully",
|
||||
"Failed to ban IP",
|
||||
)
|
||||
@@ -259,18 +275,29 @@ pub async fn dispatch(
|
||||
return DispatchOutcome::error("Avatar hash is required");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_avatar_hash(&hash).await,
|
||||
client.ban_avatar_hash(&hash, None).await,
|
||||
"Avatar hash banned successfully",
|
||||
"Failed to ban avatar hash",
|
||||
)
|
||||
}
|
||||
"schedule_deletion" => {
|
||||
let reason_code = form.parse_i32("reason_code").unwrap_or(0);
|
||||
let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
|
||||
return DispatchOutcome::error("Invalid deletion reason code");
|
||||
};
|
||||
let public_reason = get("public_reason");
|
||||
let days = form.parse_u32("days_until_deletion").unwrap_or(60);
|
||||
let private_reason = get("private_reason");
|
||||
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
|
||||
return DispatchOutcome::error("Invalid deletion delay");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.schedule_deletion(user_id, reason_code, public_reason.as_deref(), days)
|
||||
.schedule_deletion(
|
||||
user_id,
|
||||
reason_code.unwrap_or(0),
|
||||
public_reason.as_deref(),
|
||||
days.unwrap_or(60),
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User deletion scheduled successfully",
|
||||
"Failed to schedule user deletion",
|
||||
@@ -441,20 +468,6 @@ fn is_relationship_category(category: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_u64_list(form: &MultiValueForm, keys: &[&str]) -> Vec<u64> {
|
||||
form.list_values_any(keys)
|
||||
.iter()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_i32_list(form: &MultiValueForm, keys: &[&str]) -> Vec<i32> {
|
||||
form.list_values_any(keys)
|
||||
.iter()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_dry_run(value: Option<&str>) -> bool {
|
||||
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
|
||||
}
|
||||
|
||||
@@ -164,25 +164,29 @@ pub async fn render(
|
||||
Some(tabs::guilds::guilds_tab(config, user_id, &g))
|
||||
}
|
||||
"reports" => {
|
||||
let lim = query.reports_limit.unwrap_or(25);
|
||||
let sp = query.reports_sent_page.unwrap_or(0);
|
||||
let rp = query.reports_received_page.unwrap_or(0);
|
||||
let limit = query.reports_limit.unwrap_or(25);
|
||||
let sent_page = query.reports_sent_page.unwrap_or(0);
|
||||
let received_page = query.reports_received_page.unwrap_or(0);
|
||||
let sent = client
|
||||
.search_reports_by_reporter(user_id, lim, sp * lim)
|
||||
.search_reports_by_reporter(user_id, limit, u64::from(sent_page) * u64::from(limit))
|
||||
.await
|
||||
.log_error("load reports sent by user");
|
||||
let recv = client
|
||||
.search_reports_by_reported_user(user_id, lim, rp * lim)
|
||||
let received = client
|
||||
.search_reports_by_reported_user(
|
||||
user_id,
|
||||
limit,
|
||||
u64::from(received_page) * u64::from(limit),
|
||||
)
|
||||
.await
|
||||
.log_error("load reports against user");
|
||||
Some(tabs::reports::reports_tab(
|
||||
config,
|
||||
user_id,
|
||||
sent.as_ref(),
|
||||
recv.as_ref(),
|
||||
sp,
|
||||
rp,
|
||||
lim,
|
||||
received.as_ref(),
|
||||
sent_page,
|
||||
received_page,
|
||||
limit,
|
||||
))
|
||||
}
|
||||
"relationships" => {
|
||||
@@ -240,11 +244,11 @@ pub async fn render(
|
||||
query: None,
|
||||
admin_user_id: None,
|
||||
target_id: Some(user_id.to_owned()),
|
||||
target_type: Some("user".to_owned()),
|
||||
target_type: None,
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit,
|
||||
offset: page * limit,
|
||||
offset: u64::from(page) * u64::from(limit),
|
||||
})
|
||||
.await
|
||||
.log_error("load user admin audit logs")?;
|
||||
|
||||
@@ -93,7 +93,7 @@ async fn users_list(
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = params.page.saturating_mul(params.limit);
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_users(
|
||||
params.search_query(),
|
||||
@@ -105,7 +105,7 @@ async fn users_list(
|
||||
.await
|
||||
.log_error("search users")
|
||||
.map(|r| {
|
||||
let has_more = u64::from(offset) + (r.users.len() as u64) < r.total;
|
||||
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||
(r.users, has_more)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
middleware::{auth::AuthContext, csrf},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,12 +13,34 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VoiceServersQuery {
|
||||
region_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
|
||||
let response = match client.get_gateway_voice_state_counts().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: load voice state counts");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
servers
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let server_id = entry.get("server_id")?.as_str()?.to_owned();
|
||||
let count = entry.get("voice_state_count")?.as_i64()?;
|
||||
Some((server_id, count))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: None,
|
||||
region_name: None,
|
||||
servers: None,
|
||||
connection_counts: &HashMap::new(),
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
return Html(markup.into_string()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let connection_counts = load_server_connection_counts(&client).await;
|
||||
|
||||
let region_name = match client.get_voice_region(region_id, false).await {
|
||||
Ok(resp) => resp
|
||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: Some(&resp.servers),
|
||||
connection_counts: &connection_counts,
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
}
|
||||
body.insert("is_default".into(), form.bool_value("is_default").into());
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
fn insert_submitted_list(
|
||||
body: &mut serde_json::Map<String, serde_json::Value>,
|
||||
form: &MultiValueForm,
|
||||
field: &str,
|
||||
) {
|
||||
let repeated = format!("{field}[]");
|
||||
if !form.contains_key(&repeated) && !form.contains_key(field) {
|
||||
return;
|
||||
}
|
||||
body.insert(
|
||||
field.to_owned(),
|
||||
form.list_values_any(&[repeated.as_str(), field]).into(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(v) = form.clean("region_id") {
|
||||
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
body.insert("longitude".into(), lng.into());
|
||||
}
|
||||
body.insert("is_active".into(), form.bool_value("is_active").into());
|
||||
if let Some(raw) = form.first("soft_connection_limit") {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
|
||||
} else if let Ok(limit) = trimmed.parse::<i64>()
|
||||
&& limit > 0
|
||||
{
|
||||
body.insert("soft_connection_limit".into(), limit.into());
|
||||
}
|
||||
}
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
@@ -255,6 +264,86 @@ 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");
|
||||
let body = build_region_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
|
||||
let form =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
|
||||
let body = build_server_body(&form);
|
||||
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");
|
||||
assert!(
|
||||
!build_server_body(&absent)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let invalid =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
|
||||
assert!(
|
||||
!build_server_body(&invalid)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
|
||||
assert!(
|
||||
!build_server_body(&zero)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_preserves_single_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
|
||||
@@ -41,17 +41,14 @@ pub fn archives_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn status_text(archive: &Archive) -> String {
|
||||
fn status_text(archive: &Archive) -> &str {
|
||||
if archive.failed_at.is_some() {
|
||||
return "Failed".to_owned();
|
||||
return "Failed";
|
||||
}
|
||||
if archive.completed_at.is_some() {
|
||||
return "Completed".to_owned();
|
||||
return "Completed";
|
||||
}
|
||||
archive
|
||||
.progress_step
|
||||
.clone()
|
||||
.unwrap_or_else(|| "In Progress".to_owned())
|
||||
archive.progress_step.as_deref().unwrap_or("In Progress")
|
||||
}
|
||||
|
||||
fn archives_table(base: &str, archives: &[Archive]) -> Markup {
|
||||
|
||||
@@ -66,9 +66,15 @@ pub fn audit_logs_for_target(
|
||||
) -> Markup {
|
||||
let has_previous = current_page > 0;
|
||||
let offset = (current_page as u64) * (PAGE_SIZE as u64);
|
||||
let has_next = offset + (entries.len() as u64) < total;
|
||||
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64;
|
||||
let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}");
|
||||
let next_page = current_page
|
||||
.checked_add(1)
|
||||
.filter(|_| offset + (entries.len() as u64) < total);
|
||||
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
|
||||
let page_number = u64::from(current_page) + 1;
|
||||
let all_logs_href = format!(
|
||||
"{base_path}/audit-logs?target_id={}",
|
||||
urlencoding::encode(target_id)
|
||||
);
|
||||
|
||||
html! {
|
||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
|
||||
@@ -108,7 +114,7 @@ pub fn audit_logs_for_target(
|
||||
}))
|
||||
}))
|
||||
}
|
||||
@if has_previous || has_next {
|
||||
@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)}
|
||||
@@ -119,10 +125,10 @@ pub fn audit_logs_for_target(
|
||||
}
|
||||
}
|
||||
span class="text-sm text-neutral-500" {
|
||||
"Page " (current_page + 1) " of " (total_pages)
|
||||
"Page " (page_number) " of " (total_pages)
|
||||
}
|
||||
@if has_next {
|
||||
a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)}
|
||||
@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" {
|
||||
|
||||
@@ -81,20 +81,15 @@ pub fn guild_asset_url(
|
||||
}
|
||||
|
||||
pub fn initials(name: &str) -> String {
|
||||
let parts = name
|
||||
.split_whitespace()
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[] => "?".to_owned(),
|
||||
[part] => part.chars().next().unwrap_or('?').to_uppercase().collect(),
|
||||
[first, .., last] => {
|
||||
let mut value = String::new();
|
||||
value.extend(first.chars().next().unwrap_or('?').to_uppercase());
|
||||
value.extend(last.chars().next().unwrap_or('?').to_uppercase());
|
||||
value
|
||||
}
|
||||
}
|
||||
let mut parts = name.split_whitespace();
|
||||
let Some(first) = parts.next() else {
|
||||
return "?".to_owned();
|
||||
};
|
||||
std::iter::once(first)
|
||||
.chain(parts.next_back())
|
||||
.flat_map(|part| part.chars().take(1))
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn media_asset_url(
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub nsfw: Option<bool>,
|
||||
pub content_type: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub size: Option<u64>,
|
||||
pub ncmec_status: String,
|
||||
pub ncmec_report_id: Option<String>,
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
pub author_id: String,
|
||||
pub author_username: String,
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value["attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
|
||||
author_id: value_id(&value["author_id"]).unwrap_or_default(),
|
||||
author_username: value["author_username"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||
channel_content_warning_level: value["channel_content_warning_level"]
|
||||
.as_i64()
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value["channel_content_warning_text"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
url: value["url"].as_str().unwrap_or("").to_owned(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
nsfw: value["nsfw"].as_bool(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
width: value["width"].as_u64().map(|n| n as u32),
|
||||
height: value["height"].as_u64().map(|n| n as u32),
|
||||
size: value["size"].as_u64(),
|
||||
ncmec_status: value["ncmec_status"]
|
||||
.as_str()
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value["ncmec_failure_reason"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => 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,
|
||||
"ncmec_status": "submitted",
|
||||
"ncmec_report_id": "report-id",
|
||||
"ncmec_failure_reason": "previous failure"
|
||||
}]
|
||||
});
|
||||
|
||||
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()),
|
||||
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),
|
||||
ncmec_status: "submitted".into(),
|
||||
ncmec_report_id: Some("report-id".into()),
|
||||
ncmec_failure_reason: Some("previous failure".into()),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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.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,
|
||||
ncmec_status: "not_submitted".into(),
|
||||
ncmec_report_id: None,
|
||||
ncmec_failure_reason: 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");
|
||||
}
|
||||
}
|
||||
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub nsfw: Option<bool>,
|
||||
pub content_type: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub size: Option<u64>,
|
||||
pub ncmec_status: String,
|
||||
pub ncmec_report_id: Option<String>,
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
pub author_id: String,
|
||||
pub author_username: String,
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
fn is_image(att: &Attachment) -> bool {
|
||||
att.content_type
|
||||
@@ -74,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup {
|
||||
}
|
||||
|
||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect();
|
||||
if images.is_empty() {
|
||||
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||
if images.peek().is_none() {
|
||||
return html! {};
|
||||
}
|
||||
let spacer = if !msg.content.is_empty() {
|
||||
@@ -85,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
};
|
||||
html! {
|
||||
div class=(spacer) {
|
||||
@for att in &images {
|
||||
@for att in images {
|
||||
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
class="block overflow-hidden bg-neutral-100" {
|
||||
@@ -145,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
}
|
||||
|
||||
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
|
||||
let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect();
|
||||
if others.is_empty() {
|
||||
let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable();
|
||||
if others.peek().is_none() {
|
||||
return html! {};
|
||||
}
|
||||
let spacer = if has_content_or_images {
|
||||
@@ -156,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
||||
};
|
||||
html! {
|
||||
div class=(spacer) {
|
||||
@for att in &others {
|
||||
@for att in others {
|
||||
div class="flex flex-wrap items-center gap-2 text-xs" {
|
||||
(paperclip_icon("text-neutral-400"))
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
|
||||
@@ -13,10 +13,10 @@ pub mod error_display;
|
||||
pub mod form;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
pub mod pagination;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod stack;
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup {
|
||||
}
|
||||
|
||||
fn truncate(text: &str, max: usize) -> String {
|
||||
if text.len() <= max {
|
||||
if text.chars().nth(max).is_none() {
|
||||
text.to_owned()
|
||||
} else {
|
||||
let boundary = max.saturating_sub(1);
|
||||
@@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String {
|
||||
}
|
||||
|
||||
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
|
||||
let Some(lvl) = level else {
|
||||
return html! {};
|
||||
};
|
||||
if lvl != CONTENT_WARNING_LEVEL {
|
||||
if level != Some(CONTENT_WARNING_LEVEL) {
|
||||
return html! {};
|
||||
}
|
||||
let default_text = "This contains sensitive content.";
|
||||
@@ -82,13 +79,8 @@ fn resolve_nsfw_source(
|
||||
|
||||
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
|
||||
match source {
|
||||
NsfwSource::Channel => {
|
||||
if explicit == Some(true) {
|
||||
"(override on)"
|
||||
} else {
|
||||
"(override off)"
|
||||
}
|
||||
}
|
||||
NsfwSource::Channel if explicit == Some(true) => "(override on)",
|
||||
NsfwSource::Channel => "(override off)",
|
||||
NsfwSource::Category => "(from category)",
|
||||
NsfwSource::Community => "(from community)",
|
||||
NsfwSource::None => "",
|
||||
@@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge(
|
||||
}
|
||||
let has_context =
|
||||
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
|
||||
let (source, explicit) = if has_context {
|
||||
resolve_nsfw_source(nsfw_override, category_override, guild_nsfw)
|
||||
} else {
|
||||
(NsfwSource::None, None)
|
||||
};
|
||||
let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw);
|
||||
html! {
|
||||
span class="inline-flex flex-wrap items-center gap-1" {
|
||||
@if is_nsfw {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup {
|
||||
let sep = if base_url.contains('?') { "&" } else { "?" };
|
||||
let has_previous = current_page > 1;
|
||||
html! {
|
||||
div class="mt-4 flex items-center justify-between" {
|
||||
@if has_previous {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
a href={
|
||||
(base_url) (sep) "page=" (current_page - 1) (extra_params)
|
||||
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
|
||||
(PreEscaped("← Previous"))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
@if has_more {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
a href={
|
||||
(base_url) (sep) "page=" (current_page + 1) (extra_params)
|
||||
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
|
||||
(PreEscaped("Next →"))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral
|
||||
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
|
||||
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
|
||||
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
|
||||
const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
|
||||
hover:text-neutral-600 hover:decoration-neutral-500";
|
||||
|
||||
impl ResourceType {
|
||||
fn path_segment(self) -> &'static str {
|
||||
@@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup {
|
||||
a href=(href) class=(NAV_LINK_CLASS) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup {
|
||||
if external {
|
||||
html! {
|
||||
a href=(href) class=(TEXT_LINK_CLASS)
|
||||
target="_blank" rel="noopener noreferrer" {
|
||||
(content)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
a href=(href) class=(TEXT_LINK_CLASS) { (content) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
"bulk-actions",
|
||||
[
|
||||
acl::BULK_UPDATE_USER_FLAGS,
|
||||
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
@@ -264,3 +265,27 @@ 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_SUSPICIOUS_ACTIVITY,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
("", "Any"),
|
||||
("user", "User"),
|
||||
("guild", "Guild"),
|
||||
("bulk_job", "Bulk job"),
|
||||
("email_domain", "Email domain"),
|
||||
("ip", "IP"),
|
||||
("phrase", "Phrase"),
|
||||
@@ -72,27 +73,22 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
}
|
||||
|
||||
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
|
||||
let mut url = format!("{base}/audit-logs?page={page}");
|
||||
if !params.query.is_empty() {
|
||||
url.push_str(&format!("&q={}", params.query));
|
||||
}
|
||||
if !params.admin_user_id.is_empty() {
|
||||
url.push_str(&format!("&admin_user_id={}", params.admin_user_id));
|
||||
}
|
||||
if !params.target_id.is_empty() {
|
||||
url.push_str(&format!("&target_id={}", params.target_id));
|
||||
}
|
||||
if !params.target_type.is_empty() {
|
||||
url.push_str(&format!("&target_type={}", params.target_type));
|
||||
}
|
||||
if !params.sort_by.is_empty() {
|
||||
url.push_str(&format!("&sort_by={}", params.sort_by));
|
||||
}
|
||||
if !params.sort_order.is_empty() {
|
||||
url.push_str(&format!("&sort_order={}", params.sort_order));
|
||||
}
|
||||
url.push_str(&format!("&limit={}", params.limit));
|
||||
url
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
query.append_pair("page", &page.to_string());
|
||||
query.extend_pairs(
|
||||
[
|
||||
("q", params.query),
|
||||
("admin_user_id", params.admin_user_id),
|
||||
("target_id", params.target_id),
|
||||
("target_type", params.target_type),
|
||||
("sort_by", params.sort_by),
|
||||
("sort_order", params.sort_order),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty()),
|
||||
);
|
||||
query.append_pair("limit", ¶ms.limit.to_string());
|
||||
format!("{base}/audit-logs?{}", query.finish())
|
||||
}
|
||||
|
||||
pub fn audit_logs_page(
|
||||
@@ -107,10 +103,15 @@ pub fn audit_logs_page(
|
||||
let total = data.total;
|
||||
let entries = &data.logs;
|
||||
let total_pages = if params.limit > 0 {
|
||||
((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64
|
||||
total.div_ceil(u64::from(params.limit)).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let page_number = u64::from(params.current_page) + 1;
|
||||
let next_page = params
|
||||
.current_page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
let showing = format!("Showing {} of {} entries", entries.len(), total);
|
||||
html! {
|
||||
(page_header_with_actions("Audit Logs", None, html! {
|
||||
@@ -134,10 +135,10 @@ pub fn audit_logs_page(
|
||||
}
|
||||
} @else { span {} }
|
||||
span class="text-sm text-neutral-500" {
|
||||
"Page " (params.current_page + 1) " of " (total_pages)
|
||||
"Page " (page_number) " of " (total_pages)
|
||||
}
|
||||
@if (params.current_page + 1) < total_pages as u32 {
|
||||
a href=(build_pagination_url(base, params.current_page + 1, params))
|
||||
@if let Some(page) = next_page {
|
||||
a href=(build_pagination_url(base, page, params))
|
||||
class="text-sm text-neutral-900 underline" {
|
||||
(PreEscaped("Next →"))
|
||||
}
|
||||
@@ -156,3 +157,24 @@ 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",
|
||||
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>"#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,7 @@ use crate::{
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn format_action(action: &str) -> String {
|
||||
let replaced = action.replace('_', " ");
|
||||
let mut chars = replaced.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
}
|
||||
capitalise(&action.replace('_', " "))
|
||||
}
|
||||
|
||||
pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||
@@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String {
|
||||
guild
|
||||
.map(|guild| guild.name.clone())
|
||||
.unwrap_or_else(|| "Guild".to_owned())
|
||||
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str {
|
||||
guild.map(|guild| guild.name.as_str()).unwrap_or("Guild")
|
||||
}
|
||||
|
||||
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
@@ -133,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}))
|
||||
},
|
||||
"bulk_job" => html! {
|
||||
(job_link(base, &entry.target_id, html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Bulk job" }
|
||||
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||
}
|
||||
}))
|
||||
},
|
||||
"message" => html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Message" }
|
||||
@@ -154,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
|
||||
html! {
|
||||
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_href(base: &str, channel_id: &str) -> String {
|
||||
format!(
|
||||
"{base}/messages?channel_id={}&context_limit=50",
|
||||
@@ -317,3 +327,36 @@ 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 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::{
|
||||
templates::{
|
||||
components::{
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
|
||||
submit_button, text_input, textarea_input,
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -147,9 +147,14 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
||||
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",
|
||||
@@ -175,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"VERY_LARGE_GUILD",
|
||||
];
|
||||
|
||||
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)")
|
||||
} else {
|
||||
feature.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
@@ -219,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@for feature in GUILD_FEATURES {
|
||||
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
|
||||
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@@ -301,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
|
||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||
"Features to Add"
|
||||
}
|
||||
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
|
||||
(guild_feature_checkbox_grid("add_features[]", false))
|
||||
}
|
||||
div {
|
||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||
"Features to Remove"
|
||||
}
|
||||
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES))
|
||||
(guild_feature_checkbox_grid("remove_features[]", true))
|
||||
}
|
||||
(form_field_group("Custom features to add", "custom_add_features", false, None,
|
||||
Some("Comma-separated list of custom features not in the standard set."),
|
||||
@@ -368,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
|
||||
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id="reason_code" name="reason_code" required
|
||||
class=(FORM_SELECT_CLASS) {
|
||||
option value="" selected { "Select a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
))
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="14" min="14" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
}))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||
html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="60" min="14" max="365" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
@@ -408,3 +452,41 @@ 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").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").into_string();
|
||||
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||
}
|
||||
|
||||
#[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""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const MAX_GIFT_CODES: u32 = 100;
|
||||
pub const MAX_GIFT_CODES: u32 = 100;
|
||||
const DEFAULT_GIFT_COUNT: u32 = 10;
|
||||
|
||||
pub fn gift_codes_page(
|
||||
@@ -44,12 +44,12 @@ pub fn gift_codes_page(
|
||||
(csrf_input(csrf_token))
|
||||
div class="flex flex-col gap-4" {
|
||||
(form_field_group(
|
||||
"Number of codes", "gift-count-slider", false, None,
|
||||
"Number of codes", "gift-count-slider", true, None,
|
||||
Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
|
||||
html! {
|
||||
input type="number" id="gift-count-slider" name="count"
|
||||
value=(DEFAULT_GIFT_COUNT) min="1"
|
||||
max=(MAX_GIFT_CODES)
|
||||
max=(MAX_GIFT_CODES) required
|
||||
class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
|
||||
@@ -14,9 +14,14 @@ 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",
|
||||
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
|
||||
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) {
|
||||
format!("{feature} (deprecated, no longer enforced)")
|
||||
} else {
|
||||
feature.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn features_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
@@ -85,7 +100,7 @@ pub fn features_tab(
|
||||
(checkbox(
|
||||
"features[]",
|
||||
feature,
|
||||
feature,
|
||||
&feature_label(feature),
|
||||
guild.features.iter().any(|f| f == feature),
|
||||
true,
|
||||
))
|
||||
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
|
||||
@for feature in &enabled {
|
||||
span class="inline-flex items-center rounded-full bg-green-100 \
|
||||
px-2.5 py-0.5 text-xs font-medium text-green-800" {
|
||||
(feature)
|
||||
(feature_label(feature))
|
||||
}
|
||||
}
|
||||
@for feature in &custom {
|
||||
@@ -166,3 +181,56 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ pub fn members_tab(
|
||||
let total = response.total;
|
||||
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
|
||||
let has_previous = page > 0;
|
||||
let has_next = (page as u64) < total_pages.saturating_sub(1);
|
||||
let next_page = page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@@ -34,12 +36,16 @@ pub fn members_tab(
|
||||
"Guild Members (" (total) ")"
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
@let start = response.offset + 1;
|
||||
@let end = std::cmp::min(
|
||||
response.offset + response.members.len() as u64,
|
||||
total,
|
||||
);
|
||||
"Showing " (start) "-" (end) " of " (total)
|
||||
@if response.members.is_empty() {
|
||||
"Showing 0 of " (total)
|
||||
} @else {
|
||||
@let start = u128::from(response.offset) + 1;
|
||||
@let end = std::cmp::min(
|
||||
u128::from(response.offset) + response.members.len() as u128,
|
||||
u128::from(total),
|
||||
);
|
||||
"Showing " (start) "-" (end) " of " (total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +76,10 @@ pub fn members_tab(
|
||||
}
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Page " (page + 1) " of " (total_pages)
|
||||
"Page " (u64::from(page) + 1) " of " (total_pages)
|
||||
}
|
||||
@if has_next {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)}
|
||||
@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}"
|
||||
|
||||
@@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str {
|
||||
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
|
||||
let mut sorted_channels = guild.channels.clone();
|
||||
let mut sorted_channels: Vec<_> = guild.channels.iter().collect();
|
||||
sorted_channels.sort_by_key(|c| c.position);
|
||||
|
||||
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
|
||||
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
let mut sorted_roles = guild.roles.clone();
|
||||
let mut sorted_roles: Vec<_> = guild.roles.iter().collect();
|
||||
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
|
||||
|
||||
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
|
||||
@@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
|
||||
p class="text-sm font-semibold text-neutral-500" {
|
||||
"Custom warning text"
|
||||
}
|
||||
@if let Some(ref text) = guild.content_warning_text {
|
||||
@if !text.trim().is_empty() {
|
||||
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
|
||||
px-3 py-2 text-neutral-800 text-sm italic" {
|
||||
(text)
|
||||
}
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"\u{2014} (default fallback shown to users)"
|
||||
}
|
||||
@if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) {
|
||||
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
|
||||
px-3 py-2 text-neutral-800 text-sm italic" {
|
||||
(text)
|
||||
}
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" {
|
||||
|
||||
@@ -17,8 +17,9 @@ pub fn reports_tab(
|
||||
let base = &config.base_path;
|
||||
let page_size: u64 = 25;
|
||||
let has_previous = page > 0;
|
||||
let has_next = (page as u64) * page_size + reports.len() as u64 > 0
|
||||
&& (page as u64 + 1) * page_size < total;
|
||||
let next_page = page.checked_add(1).filter(|next_page| {
|
||||
(page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total
|
||||
});
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
div class="flex items-center justify-between" {
|
||||
@@ -48,7 +49,7 @@ pub fn reports_tab(
|
||||
))
|
||||
}
|
||||
|
||||
@if has_previous || has_next {
|
||||
@if has_previous || next_page.is_some() {
|
||||
div class="flex justify-center gap-2" {
|
||||
@if has_previous {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
|
||||
@@ -58,8 +59,8 @@ pub fn reports_tab(
|
||||
"\u{2190} Newer"
|
||||
}
|
||||
}
|
||||
@if has_next {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)}
|
||||
@if let Some(next_page) = next_page {
|
||||
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_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" {
|
||||
|
||||
@@ -64,7 +64,6 @@ impl GuildsListParams {
|
||||
pub struct GuildsListResults<'a> {
|
||||
pub guilds: Option<&'a [GuildInfo]>,
|
||||
pub total: Option<u64>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub fn guilds_list_page(
|
||||
@@ -73,14 +72,12 @@ pub fn guilds_list_page(
|
||||
params: &GuildsListParams,
|
||||
results: Option<&[GuildInfo]>,
|
||||
total: Option<u64>,
|
||||
has_more: bool,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let result_state = GuildsListResults {
|
||||
guilds: results,
|
||||
total,
|
||||
has_more,
|
||||
};
|
||||
let has_results = results.is_some_and(|guilds| !guilds.is_empty());
|
||||
let header = html! {
|
||||
@@ -164,7 +161,10 @@ fn render_results(
|
||||
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
|
||||
let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
|
||||
let has_previous = params.page > 0;
|
||||
let has_next = u64::from(params.page) < total_pages.saturating_sub(1);
|
||||
let next_page = params
|
||||
.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 {
|
||||
@@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
|
||||
}
|
||||
}
|
||||
span class="text-neutral-600 text-sm" {
|
||||
"Page " (params.page + 1) " of " (total_pages)
|
||||
"Page " (u64::from(params.page) + 1) " of " (total_pages)
|
||||
}
|
||||
@if has_next {
|
||||
a href=(pagination_url(&config.base_path, params, params.page + 1))
|
||||
@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}"
|
||||
@@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
|
||||
}
|
||||
|
||||
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
|
||||
let mut parts = Vec::new();
|
||||
parts.push(format!("page={page}"));
|
||||
let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)];
|
||||
if !params.q.is_empty() {
|
||||
parts.push(format!("q={}", urlencoding::encode(¶ms.q)));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ use crate::{
|
||||
templates::{
|
||||
components::{
|
||||
auto_refresh::auto_refresh,
|
||||
badge::{BadgeVariant, badge},
|
||||
data_field::{data_field_mono, data_field_text, data_grid},
|
||||
form::{csrf_input, danger_button, form_field_group},
|
||||
page_container::{page_header_with_actions, page_header_with_back},
|
||||
@@ -18,17 +17,7 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
fn status_badge(status: &str) -> Markup {
|
||||
let variant = match status {
|
||||
"queued" => BadgeVariant::Default,
|
||||
"running" => BadgeVariant::Info,
|
||||
"succeeded" => BadgeVariant::Success,
|
||||
"failed" | "deadletter" => BadgeVariant::Danger,
|
||||
"cancelled" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
};
|
||||
badge(status, variant)
|
||||
}
|
||||
use super::jobs_list_helpers::status_badge;
|
||||
|
||||
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
|
||||
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
|
||||
@@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup {
|
||||
}
|
||||
(cur, Some(tot)) => {
|
||||
let c = cur.unwrap_or(0);
|
||||
let pct = (c * 100 / tot).min(100);
|
||||
let pct = (u128::from(c) * 100 / u128::from(tot)).min(100);
|
||||
html! {
|
||||
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
|
||||
@@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String {
|
||||
(Some(cur), None | Some(0)) => format!("{cur}"),
|
||||
(cur, Some(tot)) => {
|
||||
let c = cur.unwrap_or(0);
|
||||
let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0);
|
||||
let pct = (u128::from(c) * 100)
|
||||
.checked_div(u128::from(tot))
|
||||
.unwrap_or(0);
|
||||
format!("{c} / {tot} ({pct}%)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
let job_id = job
|
||||
.get("job_id")
|
||||
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")))
|
||||
.unwrap_or("");
|
||||
let job_id_display = job
|
||||
fn job_id_text(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.get("job_id")
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
_ => v.to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
let job_id = job_id_text(job);
|
||||
let task_type = job
|
||||
.get("task_type")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
.unwrap_or_else(|| "cron".to_owned());
|
||||
let progress = format_progress(job);
|
||||
|
||||
let link_id = if job_id.is_empty() {
|
||||
&job_id_display
|
||||
} else {
|
||||
job_id
|
||||
};
|
||||
|
||||
table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="whitespace-nowrap text-sm" { (created_at) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
a href={(base) "/jobs/" (link_id)}
|
||||
a href={(base) "/jobs/" (job_id)}
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
class="text-blue-600 text-sm hover:underline" {
|
||||
(job_id_display)
|
||||
(job_id)
|
||||
}
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
@@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
}))
|
||||
(table_cell(false, html! { (status_badge(status)) }))
|
||||
(table_cell(false, html! {
|
||||
span class="text-sm" data-job-progress=(link_id) { (progress) }
|
||||
span class="text-sm" data-job-progress=(job_id) { (progress) }
|
||||
}))
|
||||
(table_cell(true, html! {
|
||||
span class="text-sm" { (attempts) "/" (max_attempts) }
|
||||
@@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
span class="text-sm" { (requester) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
a href={(base) "/jobs/" (link_id)}
|
||||
a href={(base) "/jobs/" (job_id)}
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
@@ -159,14 +154,7 @@ pub(crate) fn next_page_link(
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let job_id = cursor
|
||||
.get("job_id")
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
_ => v.to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let job_id = job_id_text(cursor);
|
||||
|
||||
let mut params = vec![
|
||||
format!("cursor_bucket_day={bucket_day}"),
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||
submit_button,
|
||||
},
|
||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -19,7 +20,6 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const MESSAGE_BROWSE_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
|
||||
show_delete: bool,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -295,7 +295,7 @@ fn browse_result_card(
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -345,7 +345,7 @@ fn search_result_card(
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -382,7 +382,7 @@ fn lookup_result_card(
|
||||
show_delete: bool,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let channel_id = messages
|
||||
.first()
|
||||
.map(|m| m.channel_id.as_str())
|
||||
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_messages(result: &Value) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = result
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(message_from_value)
|
||||
.collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value
|
||||
.get("attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
content: value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
timestamp: value
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
author_id: value
|
||||
.get("author_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
author_username: value
|
||||
.get("author_username")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value
|
||||
.get("author_global_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
author_discriminator: value
|
||||
.get("author_discriminator")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value
|
||||
.get("author_avatar")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
channel_id: value
|
||||
.get("channel_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
||||
channel_content_warning_level: value
|
||||
.get("channel_content_warning_level")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value
|
||||
.get("channel_content_warning_text")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
url: value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
filename: value
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
||||
content_type: value
|
||||
.get("content_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
||||
height: value
|
||||
.get("height")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as u32),
|
||||
size: value.get("size").and_then(Value::as_u64),
|
||||
ncmec_status: value
|
||||
.get("ncmec_status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value
|
||||
.get("ncmec_report_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value
|
||||
.get("ncmec_failure_reason")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
fn response_messages(result: &Value) -> Vec<Message> {
|
||||
let values = result["messages"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
ordered_messages(values)
|
||||
}
|
||||
|
||||
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::ReportEntry,
|
||||
@@ -13,7 +11,8 @@ use crate::{
|
||||
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
||||
form::csrf_input,
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
nsfw_indicators::{
|
||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||
},
|
||||
@@ -26,7 +25,6 @@ use crate::{
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
|
||||
fn status_badge(status: i32) -> Markup {
|
||||
let (label, variant) = match status {
|
||||
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value
|
||||
.get("attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
content: value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
timestamp: value
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
author_id: value
|
||||
.get("author_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
author_username: value
|
||||
.get("author_username")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value
|
||||
.get("author_global_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
author_discriminator: value
|
||||
.get("author_discriminator")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value
|
||||
.get("author_avatar")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
channel_id: value
|
||||
.get("channel_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
||||
channel_content_warning_level: value
|
||||
.get("channel_content_warning_level")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value
|
||||
.get("channel_content_warning_text")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
url: value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
filename: value
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
||||
content_type: value
|
||||
.get("content_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
||||
height: value
|
||||
.get("height")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as u32),
|
||||
size: value.get("size").and_then(Value::as_u64),
|
||||
ncmec_status: value
|
||||
.get("ncmec_status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value
|
||||
.get("ncmec_report_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value
|
||||
.get("ncmec_failure_reason")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +466,10 @@ fn reports_pagination(
|
||||
limit: u32,
|
||||
total: u64,
|
||||
) -> Markup {
|
||||
let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1);
|
||||
let total_pages = total.div_ceil(u64::from(limit)).max(1);
|
||||
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 {
|
||||
@@ -478,10 +481,10 @@ fn reports_pagination(
|
||||
span {}
|
||||
}
|
||||
span class="text-neutral-500 text-sm" {
|
||||
"Page " (page + 1) " of " (total_pages)
|
||||
"Page " (u64::from(page) + 1) " of " (total_pages)
|
||||
}
|
||||
@if u64::from(page + 1) < total_pages {
|
||||
a href=(reports_url(config, filters, page + 1, limit))
|
||||
@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 →"))
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
|
||||
|
||||
@if is_in_progress {
|
||||
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
|
||||
@let pct = idx.saturating_mul(100).checked_div(tot).unwrap_or(0);
|
||||
@let pct = (u128::from(idx) * 100).checked_div(u128::from(tot)).unwrap_or(0);
|
||||
div class="space-y-2" {
|
||||
div class="flex items-center justify-between" {
|
||||
p class="text-sm text-neutral-700" {
|
||||
|
||||
@@ -146,7 +146,7 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Days until deletion"))
|
||||
input type="number" name="days" value="60"
|
||||
input type="number" name="days_until_deletion" value="60"
|
||||
min="60" max="365"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
@@ -322,9 +322,8 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
|
||||
let total = value_u64(status, "total").unwrap_or(0);
|
||||
let processed = value_u64(status, "processed").unwrap_or(0);
|
||||
let skipped = value_u64(status, "skipped").unwrap_or(0);
|
||||
let percentage = processed
|
||||
.saturating_mul(100)
|
||||
.checked_div(total)
|
||||
let percentage = (u128::from(processed) * 100)
|
||||
.checked_div(u128::from(total))
|
||||
.unwrap_or(0)
|
||||
.min(100);
|
||||
html! {
|
||||
|
||||
@@ -78,9 +78,11 @@ fn report_section(
|
||||
}
|
||||
};
|
||||
|
||||
let offset = current_page as u64 * limit as u64;
|
||||
let offset = u64::from(current_page) * u64::from(limit);
|
||||
let has_previous = current_page > 0;
|
||||
let has_next = offset + (reports.len() as u64) < total;
|
||||
let next_page = current_page
|
||||
.checked_add(1)
|
||||
.filter(|_| (reports.len() as u64) < total.saturating_sub(offset));
|
||||
|
||||
let (sent_page, received_page) = if kind == "sent" {
|
||||
(current_page, other_page)
|
||||
@@ -115,7 +117,7 @@ fn report_section(
|
||||
))
|
||||
}
|
||||
|
||||
@if has_previous || has_next {
|
||||
@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 })}
|
||||
@@ -125,8 +127,8 @@ fn report_section(
|
||||
"Previous"
|
||||
}
|
||||
}
|
||||
@if has_next {
|
||||
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 })}
|
||||
@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" {
|
||||
|
||||
@@ -393,6 +393,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -403,8 +404,8 @@ fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> M
|
||||
} @else {
|
||||
span {}
|
||||
}
|
||||
@if has_more {
|
||||
a href=(users_url(base, params, params.page + 1))
|
||||
@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 >"
|
||||
}
|
||||
|
||||
@@ -18,19 +18,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::voice_servers_forms::{create_server_form, edit_server_form};
|
||||
|
||||
pub struct VoiceServersPageParams<'a> {
|
||||
pub region_id: Option<&'a str>,
|
||||
pub region_name: Option<&'a str>,
|
||||
pub servers: Option<&'a [VoiceServer]>,
|
||||
pub connection_counts: &'a HashMap<String, i64>,
|
||||
pub error: Option<&'a str>,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn voice_servers_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
region_id: Option<&str>,
|
||||
region_name: Option<&str>,
|
||||
servers: Option<&[VoiceServer]>,
|
||||
error: Option<&str>,
|
||||
csrf_token: &str,
|
||||
p: &VoiceServersPageParams<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let VoiceServersPageParams {
|
||||
region_id,
|
||||
region_name,
|
||||
servers,
|
||||
connection_counts,
|
||||
error,
|
||||
csrf_token,
|
||||
} = *p;
|
||||
let options = LayoutOptions {
|
||||
csrf_token,
|
||||
inspected_voice_region_id: region_id,
|
||||
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
|
||||
html! {},
|
||||
))
|
||||
@if let Some(servers) = servers {
|
||||
(servers_list(config, rid, servers, csrf_token))
|
||||
(servers_list(config, rid, servers, connection_counts, csrf_token))
|
||||
}
|
||||
div id="create" class="mt-8" {
|
||||
(create_server_form(config, rid, csrf_token))
|
||||
@@ -109,6 +123,7 @@ fn servers_list(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
servers: &[VoiceServer],
|
||||
connection_counts: &HashMap<String, i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if servers.is_empty() {
|
||||
@@ -121,7 +136,7 @@ fn servers_list(
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@for server in servers {
|
||||
(server_card(config, region_id, server, csrf_token))
|
||||
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +146,7 @@ fn server_card(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
server: &VoiceServer,
|
||||
connection_count: Option<i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
@@ -145,6 +161,15 @@ fn server_card(
|
||||
let lng_str = server
|
||||
.longitude
|
||||
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
|
||||
let soft_limit_str = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
|
||||
let connections_str =
|
||||
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
|
||||
let at_soft_limit = matches!(
|
||||
(server.soft_connection_limit, connection_count),
|
||||
(Some(limit), Some(count)) if limit > 0 && count >= limit
|
||||
);
|
||||
|
||||
card(html! {
|
||||
div class="mb-4 flex flex-col gap-1" {
|
||||
@@ -155,6 +180,9 @@ fn server_card(
|
||||
} @else {
|
||||
(badge("INACTIVE", BadgeVariant::Default))
|
||||
}
|
||||
@if at_soft_limit {
|
||||
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
|
||||
}
|
||||
(voice_status_badges(vip_only, has_features, has_guild_ids))
|
||||
}
|
||||
p class="text-sm text-neutral-500" { (endpoint) }
|
||||
@@ -164,6 +192,8 @@ fn server_card(
|
||||
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
|
||||
(data_field_text("Latitude", &lat_str))
|
||||
(data_field_text("Longitude", &lng_str))
|
||||
(data_field_text("Soft connection limit", &soft_limit_str))
|
||||
(data_field_text("Live connections", &connections_str))
|
||||
}
|
||||
(voice_features_list(&server.required_guild_features))
|
||||
(voice_guild_ids_list(&server.allowed_guild_ids))
|
||||
|
||||
@@ -26,6 +26,9 @@ pub fn edit_server_form(
|
||||
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
|
||||
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
|
||||
let is_active = server.is_active.unwrap_or(false);
|
||||
let soft_limit_val = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(String::new, |v| v.to_string());
|
||||
let vip_only = server.vip_only.unwrap_or(false);
|
||||
let features_csv = server.required_guild_features.join(", ");
|
||||
let guild_ids_csv = server.allowed_guild_ids.join(", ");
|
||||
@@ -57,6 +60,15 @@ pub fn edit_server_form(
|
||||
"Optional per-server coordinate override",
|
||||
))
|
||||
}
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit",
|
||||
&format!("{id_prefix}-soft-connection-limit"),
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
&soft_limit_val,
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
(form_field_with_helper(
|
||||
"API Key",
|
||||
&format!("{id_prefix}-api-key"),
|
||||
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
|
||||
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
|
||||
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
|
||||
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit (optional)",
|
||||
"new-server-soft-connection-limit",
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
"",
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
}
|
||||
div class="space-y-3" {
|
||||
(checkbox("is_active", "true", "Server is active", true, true))
|
||||
|
||||
@@ -17,11 +17,14 @@ pub fn clean_string(value: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn parse_comma_separated(value: &str) -> Vec<String> {
|
||||
comma_separated_values(value).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn comma_separated_values(value: &str) -> impl Iterator<Item = &str> {
|
||||
value
|
||||
.split([',', '\n', '\r'])
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
@@ -63,12 +66,18 @@ impl MultiValueForm {
|
||||
self.first(key).and_then(clean_string)
|
||||
}
|
||||
|
||||
pub fn parse_i32(&self, key: &str) -> Option<i32> {
|
||||
self.first(key).and_then(|value| value.parse().ok())
|
||||
pub fn parse_value<T: std::str::FromStr>(&self, key: &str) -> Result<Option<T>, T::Err> {
|
||||
self.parse_value_any(&[key])
|
||||
}
|
||||
|
||||
pub fn parse_i64(&self, key: &str) -> Option<i64> {
|
||||
self.first(key).and_then(|value| value.parse().ok())
|
||||
pub fn parse_value_any<T: std::str::FromStr>(
|
||||
&self,
|
||||
keys: &[&str],
|
||||
) -> Result<Option<T>, T::Err> {
|
||||
keys.iter()
|
||||
.find_map(|key| self.first(key))
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn parse_u32(&self, key: &str) -> Option<u32> {
|
||||
@@ -92,13 +101,21 @@ impl MultiValueForm {
|
||||
.get(key)
|
||||
.into_iter()
|
||||
.flat_map(|values| values.iter())
|
||||
.flat_map(|value| parse_comma_separated(value))
|
||||
.flat_map(|value| comma_separated_values(value))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_values_any(&self, keys: &[&str]) -> Vec<String> {
|
||||
keys.iter().flat_map(|key| self.list_values(key)).collect()
|
||||
}
|
||||
|
||||
pub fn parse_list_values<T: std::str::FromStr>(&self, keys: &[&str]) -> Result<Vec<T>, T::Err> {
|
||||
self.list_values_any(keys)
|
||||
.iter()
|
||||
.map(|value| value.parse())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_page(value: Option<&str>) -> u32 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_admin::api::generated::types as generated_types;
|
||||
use fluxer_admin::api::types;
|
||||
|
||||
#[test]
|
||||
@@ -247,6 +248,399 @@ fn deserialize_audit_logs_response() {
|
||||
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_guild_audit_logs_with_unknown_keys() {
|
||||
let json = r#"{
|
||||
"audit_log_entries": [
|
||||
{
|
||||
"id": "1508822460457747581",
|
||||
"action_type": 22,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557056",
|
||||
"reason": "spam",
|
||||
"options": {
|
||||
"delete_message_seconds": 3600,
|
||||
"future_option": {"nested": true}
|
||||
},
|
||||
"changes": [
|
||||
{"key": "future_change", "future_field": 1}
|
||||
],
|
||||
"future_entry_field": "ignored"
|
||||
},
|
||||
{
|
||||
"id": "1508822460457747580",
|
||||
"action_type": 31,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557057",
|
||||
"options": {
|
||||
"id": "1130958221824557057",
|
||||
"type": 0,
|
||||
"channel_id": "1130958221824557058",
|
||||
"role_name": "Moderators"
|
||||
},
|
||||
"changes": [
|
||||
{"key": "permissions_diff", "new_value": {"added": ["SEND_MESSAGES"], "removed": []}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1508822460457747579",
|
||||
"action_type": 11,
|
||||
"user_id": "1130650140672000000",
|
||||
"target_id": "1130958221824557059",
|
||||
"options": {"type": 998},
|
||||
"changes": [
|
||||
{"key": "name", "old_value": "old-name", "new_value": "new-name"},
|
||||
{"key": "position", "old_value": 3, "new_value": 4},
|
||||
{"key": "nsfw", "new_value": true},
|
||||
{"key": "roles", "new_value": ["1130958221824557060", "1130958221824557061"]}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [],
|
||||
"webhooks": []
|
||||
}"#;
|
||||
|
||||
let resp: generated_types::ListGuildAuditLogsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.audit_log_entries.len(), 3);
|
||||
|
||||
let ban = &resp.audit_log_entries[0];
|
||||
assert_eq!(*ban.action_type, 22);
|
||||
assert_eq!(ban.reason.as_deref(), Some("spam"));
|
||||
assert!(ban.options.is_some());
|
||||
assert_eq!(ban.changes.len(), 1);
|
||||
assert_eq!(ban.changes[0].key, "future_change");
|
||||
assert!(ban.changes[0].new_value.is_none());
|
||||
|
||||
let role_update = &resp.audit_log_entries[1];
|
||||
let options = role_update.options.as_ref().unwrap();
|
||||
assert_eq!(options.role_name.as_deref(), Some("Moderators"));
|
||||
assert_eq!(options.channel_id.as_deref(), Some("1130958221824557058"));
|
||||
assert_eq!(options.type_, Some(0.0));
|
||||
assert_eq!(role_update.changes[0].key, "permissions_diff");
|
||||
assert!(role_update.changes[0].new_value.is_some());
|
||||
|
||||
let channel_update = &resp.audit_log_entries[2];
|
||||
assert_eq!(channel_update.changes.len(), 4);
|
||||
assert_eq!(
|
||||
channel_update.changes[0]
|
||||
.old_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("old-name")
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[0]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("new-name")
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[1]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_i64()),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[2]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
channel_update.changes[3]
|
||||
.new_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|value| value.len()),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
let json = r##"{
|
||||
"sso": {
|
||||
"enabled": true,
|
||||
"enforced": false,
|
||||
"display_name": "Fluxer SSO",
|
||||
"issuer": "https://id.example.com",
|
||||
"authorization_url": "https://id.example.com/authorize",
|
||||
"token_url": "https://id.example.com/token",
|
||||
"userinfo_url": "https://id.example.com/userinfo",
|
||||
"jwks_url": "https://id.example.com/jwks.json",
|
||||
"client_id": "fluxer-admin",
|
||||
"client_secret_set": true,
|
||||
"scope": "openid profile email",
|
||||
"allowed_domains": ["fluxer.com"],
|
||||
"auto_provision": true,
|
||||
"redirect_uri": "https://fluxer.com/sso/callback"
|
||||
},
|
||||
"gateway_rollout": {
|
||||
"session_rollout_percentage": 100,
|
||||
"session_rollout_mode": "modulo",
|
||||
"guild_rollout_percentage": 100,
|
||||
"rpc_request_timeout_ms": 10000,
|
||||
"max_concurrent_session_starts": 512,
|
||||
"max_concurrent_guild_starts": 256,
|
||||
"gateway_dispatch_relay_shards": 32,
|
||||
"gateway_dispatch_relay_max_queue": 50000,
|
||||
"voice_e2ee_scope": "guild_feature_only",
|
||||
"future_rollout_knob": 3
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": true,
|
||||
"config_version": 4,
|
||||
"default_backend": "rnnoise",
|
||||
"enabled_backends": ["none", "standard", "rnnoise"],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 10000,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"guild_activity_log_presentation": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "guild-activity-log-presentation-v1",
|
||||
"included_user_ids": ["1130650140672000000"],
|
||||
"excluded_user_ids": [],
|
||||
"future_presentation_knob": "verbose"
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"message_hover_tracking": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "message-hover-tracking-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"message_keyboard_focus": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "message-keyboard-focus-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"blocked_message_groups": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "blocked-message-groups-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"expression_info_card": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "expression-info-card-v1",
|
||||
"included_user_ids": ["1130650140672000000"],
|
||||
"excluded_user_ids": ["1130958221824557056"],
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"typing_indicator_rework": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "typing-indicator-rework-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
"urls": [],
|
||||
"pending_registrations": []
|
||||
},
|
||||
"self_hosted": false,
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"product_name": "Fluxer",
|
||||
"icon_url": "https://cdn.example.com/icon.png",
|
||||
"symbol_url": "https://cdn.example.com/symbol.svg",
|
||||
"logo_url": "https://cdn.example.com/logo.svg",
|
||||
"wordmark_url": "https://cdn.example.com/wordmark.svg",
|
||||
"favicon_url": "https://cdn.example.com/favicon.ico",
|
||||
"theme_color": "#5865f2",
|
||||
"future_asset_url": "https://cdn.example.com/future.png"
|
||||
},
|
||||
"setup": {"configured": true},
|
||||
"legal": {
|
||||
"terms_url": "https://fluxer.com/terms",
|
||||
"privacy_url": "https://fluxer.com/privacy"
|
||||
},
|
||||
"registration": {"collect_date_of_birth": true}
|
||||
},
|
||||
"policy": {
|
||||
"single_community_enabled": false,
|
||||
"single_community_guild_id": null,
|
||||
"direct_messages_disabled": false,
|
||||
"direct_messages_locked": false,
|
||||
"premium_mode": "mirror",
|
||||
"services": {
|
||||
"gif_enabled": true,
|
||||
"youtube_enabled": true,
|
||||
"bluesky_enabled": false,
|
||||
"future_service_enabled": true
|
||||
},
|
||||
"services_resolved": {
|
||||
"gif_enabled": true,
|
||||
"youtube_enabled": true,
|
||||
"bluesky_enabled": false
|
||||
},
|
||||
"services_available": {"gif": true, "youtube": true, "bluesky": false},
|
||||
"deferred_phone_gate": {
|
||||
"enabled": false,
|
||||
"window_hours": 24,
|
||||
"member_threshold": 100
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"gif": {"klipy_api_key_set": true, "effective_available": true},
|
||||
"youtube": {"api_key_set": true, "effective_available": true},
|
||||
"captcha": {
|
||||
"provider": "hcaptcha",
|
||||
"effective_provider": "hcaptcha",
|
||||
"hcaptcha_site_key": "site",
|
||||
"hcaptcha_secret_key_set": true,
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key_set": false,
|
||||
"effective_enabled": true
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
"provider": "smtp",
|
||||
"effective_provider": "smtp",
|
||||
"from_email": "[email protected]",
|
||||
"from_name": "Fluxer",
|
||||
"smtp": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "fluxer",
|
||||
"password_set": true,
|
||||
"secure": true
|
||||
},
|
||||
"disable_new_ip_authorization": false,
|
||||
"effective_disable_new_ip_authorization": false
|
||||
},
|
||||
"bluesky": {
|
||||
"enabled": false,
|
||||
"effective_enabled": false,
|
||||
"client_name": "Fluxer",
|
||||
"client_uri": "https://fluxer.com",
|
||||
"logo_uri": "https://cdn.example.com/logo.svg",
|
||||
"tos_uri": "https://fluxer.com/terms",
|
||||
"policy_uri": "https://fluxer.com/privacy",
|
||||
"key_count": 0
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"attachment_decay": {
|
||||
"enabled": true,
|
||||
"min_size_mb": 10,
|
||||
"max_size_mb": 500,
|
||||
"max_eligible_size_mb": 500,
|
||||
"min_lifetime_days": 30,
|
||||
"max_lifetime_days": 365,
|
||||
"curve": 2,
|
||||
"renew_threshold_days": 7,
|
||||
"renew_window_days": 14,
|
||||
"effective": {
|
||||
"enabled": true,
|
||||
"min_size_mb": 10,
|
||||
"max_size_mb": 500,
|
||||
"max_eligible_size_mb": 500,
|
||||
"min_lifetime_days": 30,
|
||||
"max_lifetime_days": 365,
|
||||
"curve": 2,
|
||||
"renew_threshold_days": 7,
|
||||
"renew_window_days": 14,
|
||||
"future_curve": 1.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"future_section": {"enabled": true, "rollout_basis_points": 10000},
|
||||
"future_flag": 3
|
||||
}"##;
|
||||
|
||||
let resp: generated_types::InstanceConfigResponse = serde_json::from_str(json).expect(
|
||||
"the build.rs relaxation must keep unknown response keys from failing the whole response",
|
||||
);
|
||||
|
||||
assert!(!resp.self_hosted);
|
||||
assert!(resp.expression_info_card.enabled);
|
||||
assert_eq!(resp.expression_info_card.config_version, 3);
|
||||
assert_eq!(resp.expression_info_card.rollout_basis_points, 2500);
|
||||
assert_eq!(
|
||||
*resp.expression_info_card.rollout_salt,
|
||||
"expression-info-card-v1"
|
||||
);
|
||||
assert_eq!(resp.expression_info_card.included_user_ids.len(), 1);
|
||||
assert_eq!(
|
||||
*resp.expression_info_card.excluded_user_ids[0],
|
||||
"1130958221824557056"
|
||||
);
|
||||
assert!(resp.guild_activity_log_presentation.enabled);
|
||||
assert_eq!(
|
||||
*resp.guild_activity_log_presentation.rollout_salt,
|
||||
"guild-activity-log-presentation-v1"
|
||||
);
|
||||
assert!(!resp.message_hover_tracking.enabled);
|
||||
assert!(!resp.message_keyboard_focus.enabled);
|
||||
assert!(!resp.blocked_message_groups.enabled);
|
||||
assert!(!resp.guild_header_collapse.enabled);
|
||||
assert!(!resp.typing_indicator_rework.enabled);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
|
||||
assert!(resp.media.attachment_decay.effective.enabled);
|
||||
|
||||
let without_unknown_keys = json
|
||||
.replace("\"future_rollout_knob\": 3,", "")
|
||||
.replace("\"future_presentation_knob\": \"verbose\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
"\"future_asset_url\": \"https://cdn.example.com/future.png\",",
|
||||
"",
|
||||
)
|
||||
.replace("\"future_service_enabled\": true,", "")
|
||||
.replace("\"future_curve\": 1.5,", "")
|
||||
.replace(
|
||||
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
|
||||
"",
|
||||
)
|
||||
.replace("\"future_flag\": 3,", "");
|
||||
let baseline: generated_types::InstanceConfigResponse =
|
||||
serde_json::from_str(&without_unknown_keys).expect("known keys alone still deserialize");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&baseline).unwrap(),
|
||||
serde_json::to_value(&resp).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
api::{generated::types as generated_types, types::LookupGuildResponse},
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
@@ -430,6 +431,10 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
&[
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_guild_header_collapse",
|
||||
"/instance-config?action=update_typing_indicator_rework",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
];
|
||||
@@ -903,8 +908,8 @@ fn user(id: &str, username: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_guild() -> Value {
|
||||
json!({
|
||||
fn searched_guild() -> generated_types::GuildAdminResponse {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"name": "Searched Guild",
|
||||
"icon": null,
|
||||
@@ -917,15 +922,14 @@ fn searched_guild() -> Value {
|
||||
"features": ["COMMUNITY"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": "Guild used by HTMX acceptance tests.",
|
||||
"vanity_url_code": null
|
||||
})
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}))
|
||||
.expect("guild search fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_guild_detail() -> Value {
|
||||
json!({
|
||||
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": "SearchedUser",
|
||||
@@ -942,7 +946,7 @@ fn searched_guild_detail() -> Value {
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 0,
|
||||
"default_message_notifications": 0,
|
||||
@@ -954,9 +958,23 @@ fn searched_guild_detail() -> Value {
|
||||
"disabled_operations": 0,
|
||||
"member_count": 12,
|
||||
"channels": [],
|
||||
"roles": [],
|
||||
"description": "Guild used by HTMX acceptance tests."
|
||||
})
|
||||
"roles": []
|
||||
}))
|
||||
.expect("guild detail fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_fixtures_match_generated_response_contracts() {
|
||||
let search = searched_guild();
|
||||
assert_eq!(search.name, "Searched Guild");
|
||||
assert_eq!(*search.member_count, 12);
|
||||
|
||||
let response: LookupGuildResponse =
|
||||
serde_json::from_value(json!({"guild": searched_guild_detail()})).unwrap();
|
||||
let detail = response.guild.unwrap();
|
||||
assert_eq!(detail.name, "Searched Guild");
|
||||
assert_eq!(detail.id, "1600000000000000001");
|
||||
assert_eq!(detail.member_count, 12);
|
||||
}
|
||||
|
||||
fn searched_application() -> Value {
|
||||
@@ -1081,6 +1099,48 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"typing_indicator_rework": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "typing-indicator-rework-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
},
|
||||
"registration": registration_config(),
|
||||
"self_hosted": false
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#[path = "parity/mod.rs"]
|
||||
mod parity_support;
|
||||
|
||||
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
|
||||
use parity_support::{
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
@@ -48,6 +49,31 @@ fn html_normalizer_allows_intentional_rust_markup_fixes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_search_fixture_matches_the_generated_response_contract() {
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
|
||||
.expect("guild search fixture must match the generated response contract");
|
||||
assert_eq!(response.guilds.len(), 1);
|
||||
let guild = &response.guilds[0];
|
||||
assert_eq!(guild.name, "Parity Guild");
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_lookup_fixture_matches_the_generated_response_contract() {
|
||||
let response: LookupGuildResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
|
||||
.expect("guild lookup fixture must match the generated response contract");
|
||||
let guild = response
|
||||
.guild
|
||||
.expect("guild lookup fixture must contain a guild");
|
||||
assert_eq!(String::from(guild.name), "Parity Guild");
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
assert_eq!(guild.channels.len(), 1);
|
||||
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
|
||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 2,
|
||||
"default_message_notifications": 1,
|
||||
@@ -36,7 +36,7 @@
|
||||
"parent_id": null,
|
||||
"nsfw": false,
|
||||
"nsfw_override": null,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"url": null
|
||||
}
|
||||
@@ -51,7 +51,6 @@
|
||||
"hoist": false,
|
||||
"mentionable": false
|
||||
}
|
||||
],
|
||||
"description": "Guild used by admin parity fixtures."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": "Guild used by admin parity fixtures.",
|
||||
"vanity_url_code": "parity"
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
|
||||
const REGION_ID: &str = "europe-north";
|
||||
const SERVER_ID: &str = "europe-north-server-1";
|
||||
|
||||
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
|
||||
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
assert_eq!(body["soft_connection_limit"], Value::Null);
|
||||
assert_eq!(body["vip_only"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
let object = body.as_object().expect("object body");
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], json!(false));
|
||||
assert_eq!(body["vip_only"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-regions?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
|
||||
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
captured: CapturedBodies,
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
|
||||
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
captured,
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_body(app: &TestApp, route: &str) -> Value {
|
||||
let captured = app.captured.lock().expect("captured bodies");
|
||||
captured
|
||||
.iter()
|
||||
.find(|(seen, _)| seen == route)
|
||||
.map(|(_, body)| body.clone())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no request captured for {route}, saw {:?}",
|
||||
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_token(app: &TestApp) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/voice-regions")
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
.expect("csrf_token cookie")
|
||||
}
|
||||
|
||||
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
||||
)
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(captured: CapturedBodies) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new().fallback(mock_api).with_state(captured),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(
|
||||
State(captured): State<CapturedBodies>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method == Method::PATCH {
|
||||
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
captured
|
||||
.lock()
|
||||
.expect("captured bodies")
|
||||
.push((format!("PATCH {path}"), body));
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north") => {
|
||||
Json(json!({ "region": region() })).into_response()
|
||||
}
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
|
||||
Json(json!({ "server": server() })).into_response()
|
||||
}
|
||||
(Method::GET, "/admin/voice/regions") => {
|
||||
Json(json!({ "regions": [region()] })).into_response()
|
||||
}
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> Value {
|
||||
json!({
|
||||
"id": REGION_ID,
|
||||
"name": "Northern Europe",
|
||||
"emoji": "flag",
|
||||
"latitude": 59.33,
|
||||
"longitude": 18.06,
|
||||
"is_default": true,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn server() -> Value {
|
||||
json!({
|
||||
"region_id": REGION_ID,
|
||||
"server_id": SERVER_ID,
|
||||
"endpoint": "wss://voice.example.com",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"is_active": true,
|
||||
"soft_connection_limit": null,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_user() -> Value {
|
||||
json!({
|
||||
"id": "1500000000000000000",
|
||||
"username": "AdminUser",
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"global_name": "AdminUser",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @fluxer/config run --if-present generate
|
||||
RUN pnpm --filter fluxer_api run build
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"@pkgs/email": "workspace:*",
|
||||
"@pkgs/geoip": "workspace:*",
|
||||
"@pkgs/http_client": "workspace:*",
|
||||
"@pkgs/initialization": "workspace:*",
|
||||
"@pkgs/kv_client": "workspace:*",
|
||||
"@pkgs/locale": "workspace:*",
|
||||
"@pkgs/media_proxy_utils": "workspace:*",
|
||||
@@ -92,7 +91,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"msw": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
|
||||
Vendored
-1
@@ -17,7 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -6,12 +6,8 @@ import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
|
||||
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
|
||||
try {
|
||||
return {hit: true, value: JSON.parse(value)};
|
||||
} catch (error) {
|
||||
if (logger) {
|
||||
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
|
||||
}
|
||||
} catch {
|
||||
logger?.error({valueLength: value.length}, '[CacheProvider] JSON parse error');
|
||||
return {hit: false};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user