mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec681e6061 | ||
|
|
0fc6fad11d | ||
|
|
eb329f2cd0 | ||
|
|
9a20821332 | ||
|
|
4749eb7f86 | ||
|
|
2d83fd2a93 | ||
|
|
d180af6ba2 | ||
|
|
4810eb3e24 | ||
|
|
48a19dedd8 | ||
|
|
cadf7ea532 | ||
|
|
fc60d05f17 | ||
|
|
5072488f1e | ||
|
|
7a5b9a0ded | ||
|
|
4ce4d4e09a | ||
|
|
82abd03a37 | ||
|
|
93d1f8af46 | ||
|
|
a19002652b | ||
|
|
6534ee4300 | ||
|
|
4db13b0375 | ||
|
|
a5c1362f4a | ||
|
|
454fefdb92 | ||
|
|
f390e610b9 | ||
|
|
ece622e800 | ||
|
|
bc17922b02 | ||
|
|
a32d8e4f52 | ||
|
|
12daeb758d | ||
|
|
fff67ed9af | ||
|
|
d0b4816a5f | ||
|
|
0febad324c | ||
|
|
dcc3273889 | ||
|
|
e9a4f33a7e | ||
|
|
ae820ba4ea | ||
|
|
94f1239050 | ||
|
|
acc1392a53 | ||
|
|
a6a9789091 | ||
|
|
fdb7410976 | ||
|
|
b146b8ee33 | ||
|
|
4b1afc953f | ||
|
|
4ecbe9603c | ||
|
|
847d449882 | ||
|
|
bef20cba85 | ||
|
|
ebf91ccc76 | ||
|
|
abfaff16c2 | ||
|
|
89fa895a0a | ||
|
|
334fef52e1 | ||
|
|
aa42bf13ce | ||
|
|
c486c2d3d0 | ||
|
|
0ec1cee99e | ||
|
|
ccbe4857a1 | ||
|
|
7f8b0afedb | ||
|
|
e94b7d7ad8 | ||
|
|
27baa38fd1 | ||
|
|
4af10dd3e3 | ||
|
|
269d33cab2 | ||
|
|
170e12595b | ||
|
|
b82bbaa2dd | ||
|
|
b8f3dbddbf | ||
|
|
663572fd10 | ||
|
|
5033cf7203 | ||
|
|
0fcab2f8f0 | ||
|
|
c3d790f664 | ||
|
|
0906a85d6f | ||
|
|
4da8dec4ac | ||
|
|
d6e3254a16 | ||
|
|
6dc698ff5e | ||
|
|
39e48458da | ||
|
|
c5453f83e8 | ||
|
|
510cc1b916 | ||
|
|
e7859fefb1 | ||
|
|
46a356cc40 | ||
|
|
bd56174870 | ||
|
|
965bb5f634 | ||
|
|
7937d5f802 | ||
|
|
f1373a63c3 | ||
|
|
e0d7625c39 | ||
|
|
f8505d19f7 | ||
|
|
1515a8487f | ||
|
|
7a4aa42d4b | ||
|
|
0ef1cd14c3 | ||
|
|
795e190bda | ||
|
|
d00389ab30 | ||
|
|
3071bc7525 | ||
|
|
6de6be2358 | ||
|
|
c51c222c47 | ||
|
|
e218ba21de | ||
|
|
de358c0def | ||
|
|
4b88d64b2d | ||
|
|
ee1c861eb2 | ||
|
|
6a24ac3f4e | ||
|
|
8b312c610e | ||
|
|
2e3e3a1536 |
@@ -9,6 +9,33 @@ max_line_length = 120
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.{erl,hrl,app.src,escript}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 96
|
||||
|
||||
[fluxer_gateway/{rebar.config,elvis.config,.erlfmt}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 96
|
||||
|
||||
[*.{c,h,py}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[fluxer_app/rust/libfluxwebp/**.{c,h}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{md,mdx,proto,tpl}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{yml,yaml,Dockerfile}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
name: build desktop modules
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
default: canary
|
||||
build_version:
|
||||
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
allow_shell_drift:
|
||||
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-modules-${{ inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
name: Resolve build metadata
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
build_channel: ${{ steps.meta.outputs.build_channel }}
|
||||
source_sha: ${{ steps.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- 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: Set metadata
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_metadata
|
||||
--channel "${{ inputs.channel }}"
|
||||
|
||||
shared_assets:
|
||||
name: Build shared renderer assets
|
||||
needs:
|
||||
- meta
|
||||
runs-on: ubuntu-24.04
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout CI helpers
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: _ci
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: source
|
||||
|
||||
- name: Set up Rust toolchain (renderer wasm)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Set workdir (Unix)
|
||||
env:
|
||||
SUBST_TARGET: ${{ github.workspace }}/source
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_workdir_unix
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Set up pnpm
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step setup_pnpm
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step resolve_pnpm_store_unix
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-pnpm-store-
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-cargo-registry-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step install_dependencies
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step update_version
|
||||
|
||||
- name: Set build channel
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Build shared renderer assets
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_shared_assets
|
||||
|
||||
- name: Prepare shared renderer artifact
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_shared_assets
|
||||
|
||||
- name: Upload shared renderer artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Split renderer into desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step split_modules
|
||||
|
||||
- name: Pack desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step pack_modules
|
||||
|
||||
- name: Upload desktop module packages
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: |
|
||||
source/desktop-modules/classification.json
|
||||
source/desktop-modules/*/module.json
|
||||
source/desktop-modules/*/package.br
|
||||
source/desktop-modules/*/package.br.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
assemble:
|
||||
name: Assemble the modules-only release
|
||||
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download desktop module packages
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: desktop-modules
|
||||
|
||||
- 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: Check the live shell runs these modules
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step check_shell_drift
|
||||
|
||||
- name: Build the modules-only manifests
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_modules_only_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_modules_release_assets
|
||||
|
||||
- name: Upload GitHub release assets
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: fluxer-desktop-modules-release-assets
|
||||
path: release_assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
publish_release:
|
||||
name: Publish GitHub modules-only release
|
||||
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- assemble
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: fluxer-desktop-modules-release-assets
|
||||
path: release_assets
|
||||
|
||||
- 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: write
|
||||
- name: Publish GitHub modules-only release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_args=(
|
||||
release publish
|
||||
--component "fluxer-desktop-${CHANNEL}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
@@ -551,6 +551,16 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_windows_unpacked_signatures
|
||||
|
||||
- name: Verify the packaged shell starts and boots its bundled renderer (Windows)
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'x64'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
|
||||
BUILD_VERSION: ${{ env.VERSION }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_bundled_renderer_windows
|
||||
|
||||
- name: Create portable ZIP (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
@@ -591,6 +601,16 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_app_linux
|
||||
|
||||
- name: Verify the packaged shell boots its bundled renderer (Linux)
|
||||
if: matrix.platform == 'linux' && matrix.arch == 'x64'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
|
||||
BUILD_VERSION: ${{ env.VERSION }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step verify_bundled_renderer_linux
|
||||
|
||||
- name: Verify signed Windows artifacts
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
@@ -651,13 +671,59 @@ jobs:
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
verify_windows_arm64:
|
||||
name: Verify windows (arm64)
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- build
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
sparse-checkout: fluxer_desktop/scripts
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download windows arm64 build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-windows-arm64*
|
||||
|
||||
- name: Verify the packaged shell starts and boots its bundled renderer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$zip = Get-ChildItem -Path artifacts -Recurse -Filter "*-$env:VERSION-portable-win-arm64.zip" | Select-Object -First 1
|
||||
if ($null -eq $zip) { Write-Host 'This build has no windows arm64 portable zip'; exit 0 }
|
||||
$app = Join-Path $env:RUNNER_TEMP 'app'
|
||||
Expand-Archive -Path $zip.FullName -DestinationPath $app
|
||||
$exe = Get-ChildItem -Path $app -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { throw "$($zip.Name) holds no Fluxer executable" }
|
||||
node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --package-origin http://127.0.0.1:9 --app-arg=--disable-gpu --workdir (Join-Path $env:RUNNER_TEMP 'runs') --timeout-seconds 240
|
||||
if ($LASTEXITCODE -ne 0) { throw "The windows arm64 build fails its release check" }
|
||||
|
||||
upload:
|
||||
name: Assemble desktop release assets
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && needs.verify_windows_arm64.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
- build
|
||||
- verify_windows_arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
name: desktop windows release check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
default: canary
|
||||
version:
|
||||
description: Published version to check
|
||||
required: true
|
||||
type: string
|
||||
expect_failure:
|
||||
description: Pass only when the published build fails to start
|
||||
type: boolean
|
||||
default: false
|
||||
update_from:
|
||||
description: Comma-separated published versions to install with Setup and update to the version above from the live feed
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
stale_apply_record:
|
||||
description: Before each update, leave behind an hour-old record of an update that never installed (passes only when the installed versions retry it)
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/desktop-windows-release-check.yaml
|
||||
- fluxer_desktop/scripts/release-check.mjs
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-windows-release-check-${{ github.ref }}-${{ inputs.version || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check windows (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
os: windows-2025
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'canary' }}
|
||||
VERSION: ${{ inputs.version || '2026.1009.20411' }}
|
||||
EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }}
|
||||
UPDATE_FROM: ${{ inputs.update_from || '' }}
|
||||
STALE_APPLY_RECORD: ${{ inputs.stale_apply_record && 'true' || 'false' }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
sparse-checkout: fluxer_desktop/scripts
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check the portable build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$work = Join-Path $env:RUNNER_TEMP 'portable'
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$zip = Join-Path $work 'portable.zip'
|
||||
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$env:VERSION/portable" -OutFile $zip
|
||||
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'app')
|
||||
$exe = Get-ChildItem -Path (Join-Path $work 'app') -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { throw "The portable build holds no Fluxer executable" }
|
||||
$output = & node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --app-arg=--disable-gpu --workdir (Join-Path $work 'runs') --timeout-seconds 240 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
$output | ForEach-Object { Write-Host $_ }
|
||||
if ($env:EXPECT_FAILURE -eq 'true') {
|
||||
if ($code -eq 0) { throw "Expected $env:VERSION to fail its release check, it passed" }
|
||||
Write-Host "$env:VERSION fails its release check on windows $env:ARCH as expected"
|
||||
exit 0
|
||||
}
|
||||
if ($code -ne 0) { throw "The release check failed for $env:VERSION on windows $env:ARCH" }
|
||||
|
||||
- name: Update installed builds from the live feed
|
||||
if: env.UPDATE_FROM != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$channelDirs = @{ canary = 'fluxer_desktop_canary'; stable = 'fluxer_desktop' }
|
||||
$dataDirs = @{ canary = 'fluxercanary'; stable = 'fluxer' }
|
||||
$installRoot = Join-Path $env:LOCALAPPDATA $channelDirs[$env:CHANNEL]
|
||||
$logPath = Join-Path (Join-Path $env:APPDATA $dataDirs[$env:CHANNEL]) 'logs\main.log'
|
||||
$failures = @()
|
||||
foreach ($from in ($env:UPDATE_FROM -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
|
||||
Write-Host "== install $from, then update to $env:VERSION"
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $installRoot) { Remove-Item -Recurse -Force $installRoot }
|
||||
Remove-Item -Recurse -Force (Split-Path (Split-Path $logPath -Parent) -Parent) -ErrorAction SilentlyContinue
|
||||
$setup = Join-Path $env:RUNNER_TEMP "setup-$from.exe"
|
||||
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$from/setup" -OutFile $setup
|
||||
Start-Process -FilePath $setup -ArgumentList '--silent' -Wait
|
||||
Start-Sleep -Seconds 5
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
$exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
|
||||
if ($null -eq $exe) { $failures += "$from did not install"; continue }
|
||||
if ($env:STALE_APPLY_RECORD -eq 'true') {
|
||||
$dataRoot = Split-Path (Split-Path $logPath -Parent) -Parent
|
||||
New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null
|
||||
$attemptedAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds()
|
||||
Set-Content -Path (Join-Path $dataRoot 'update-apply-state.json') -Value "{`"version`":`"$env:VERSION`",`"attemptedAt`":$attemptedAt}" -NoNewline
|
||||
Write-Host "left a stale apply record for $env:VERSION"
|
||||
}
|
||||
Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu'
|
||||
$deadline = (Get-Date).AddMinutes(8)
|
||||
$reported = $null
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Seconds 10
|
||||
if (Test-Path $logPath) {
|
||||
$line = Select-String -Path $logPath -Pattern "The renderer reported its build: .*Desktop $([regex]::Escape($env:VERSION)), Web $([regex]::Escape($env:VERSION))" | Select-Object -Last 1
|
||||
if ($null -ne $line) { $reported = $line.Line; break }
|
||||
}
|
||||
}
|
||||
$installed = Get-Content (Join-Path $installRoot 'current\sq.version') -Raw -ErrorAction SilentlyContinue
|
||||
Write-Host "installed package after update: $installed"
|
||||
$logs = Join-Path $env:RUNNER_TEMP "logs\$from"
|
||||
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||
if (Test-Path $logPath) { Copy-Item $logPath $logs }
|
||||
Get-ChildItem -Path $installRoot -Filter '*.log' -ErrorAction SilentlyContinue | Copy-Item -Destination $logs
|
||||
if (Test-Path $logPath) { Get-Content $logPath | Select-String -Pattern 'Bootstrap|ShellSelfUpdate|Velopack|velopack|NativeModulePreflight|reported its build' | Select-Object -Last 40 | ForEach-Object { Write-Host $_.Line } }
|
||||
if ($null -eq $reported) { $failures += "$from did not reach Desktop and Web $env:VERSION" } else { Write-Host "$from updated: $reported" }
|
||||
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($failures.Count -gt 0) { throw ($failures -join "`n") }
|
||||
|
||||
- name: Upload update logs
|
||||
if: always() && env.UPDATE_FROM != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: update-logs-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/logs
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -61,6 +61,8 @@ jobs:
|
||||
--step install_dependencies
|
||||
|
||||
- name: Refresh source catalogs
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: pnpm i18n:source-sync
|
||||
|
||||
- name: Format generated catalogs
|
||||
|
||||
@@ -476,6 +476,9 @@ jobs:
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
- name: Check data-flx attributes
|
||||
run: pnpm --filter fluxer_app theming:data-flx:check
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
@@ -502,7 +505,6 @@ jobs:
|
||||
run: |
|
||||
if ! git diff --exit-code -- \
|
||||
packages/errors/src/i18n/locales \
|
||||
packages/errors/src/i18n/ErrorI18nTypes.generated.ts \
|
||||
fluxer_api/pkgs/email/src/email_i18n/locales \
|
||||
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
|
||||
fluxer_api/src/api/content_i18n/locales; then
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
**/auto-i18n-reviewed-unchanged.json.lock
|
||||
**/weblate/locales/auto-i18n-reviewed-unchanged.json
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
/fluxer_app/src/features/ui/components/SVGMasks.tsx
|
||||
|
||||
Generated
+2
-158
@@ -863,24 +863,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -1048,12 +1036,6 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -1100,16 +1082,6 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1685,15 +1657,6 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
@@ -1739,7 +1702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -1752,7 +1715,6 @@ dependencies = [
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hex",
|
||||
@@ -1776,10 +1738,8 @@ dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"libc",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -1801,12 +1761,10 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1863,7 +1821,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
@@ -1884,15 +1841,12 @@ dependencies = [
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"linkify",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1949,13 +1903,11 @@ dependencies = [
|
||||
"deadpool-postgres",
|
||||
"futures",
|
||||
"libc",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"rustls",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
@@ -1974,7 +1926,6 @@ dependencies = [
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
"regex",
|
||||
@@ -1982,7 +1933,6 @@ dependencies = [
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -2000,7 +1950,6 @@ dependencies = [
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2017,7 +1966,6 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
@@ -2273,16 +2221,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -2672,34 +2610,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -3025,16 +2935,6 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -3076,16 +2976,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.9.0"
|
||||
@@ -3469,19 +3359,6 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -3654,24 +3531,12 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -4184,7 +4049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
@@ -5616,12 +5481,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
@@ -6010,18 +5869,3 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
Vendored
-1
@@ -112,7 +112,6 @@ FLUXER_SEARCH_USERNAME=
|
||||
FLUXER_SEARCH_PASSWORD=
|
||||
FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=false
|
||||
FLUXER_STRIPE_ENABLED=false
|
||||
FLUXER_NCMEC_ENABLED=false
|
||||
FLUXER_CLAMAV_ENABLED=false
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
FLUXER_SELF_HOSTED=true
|
||||
|
||||
@@ -94,7 +94,6 @@ skip = [
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||
|
||||
@@ -142,14 +142,6 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_VISIONARIES_GUILD_ID=
|
||||
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
|
||||
|
||||
# NCMEC CyberTipline reporting, off by default. All four values are required
|
||||
# once it is on. The values below are examples.
|
||||
#FLUXER_NCMEC_ENABLED=true
|
||||
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
|
||||
#FLUXER_NCMEC_USERNAME=
|
||||
#FLUXER_NCMEC_PASSWORD=
|
||||
#[email protected]
|
||||
|
||||
# Upload virus scanning, off by default. No ClamAV container ships, so point
|
||||
# this at your own. The values below are examples.
|
||||
#FLUXER_CLAMAV_ENABLED=true
|
||||
@@ -314,7 +306,7 @@ FLUXER_KLIPY_API_KEY=
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||
FLUXER_EMAIL_FROM_NAME=
|
||||
#[email protected]
|
||||
FLUXER_EMAIL_APP_BASE_URL=
|
||||
FLUXER_EMAIL_SMTP_HOST=
|
||||
@@ -327,7 +319,10 @@ FLUXER_EMAIL_SMTP_SECURE=true
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
|
||||
|
||||
# Instance identity and account policy.
|
||||
# Instance identity and account policy. The terms, privacy and community
|
||||
# guidelines links have no variable here. Set them in the admin panel under
|
||||
# Instance Config. Until a guidelines link is set, the clients, report forms and
|
||||
# enforcement emails show none.
|
||||
#FLUXER_APP_PRODUCT_NAME=Fluxer
|
||||
#FLUXER_APP_ICON_URL=
|
||||
#FLUXER_APP_SYMBOL_URL=
|
||||
@@ -345,6 +340,17 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_AUTO_JOIN_INVITE_CODE=
|
||||
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
|
||||
|
||||
# Report retention. A daily job deletes each report this many days after it was
|
||||
# filed, with its evidence copies and search entry, unless a legal hold is set.
|
||||
#FLUXER_REPORT_RETENTION_DAYS=365
|
||||
# Delete resolved reports this many days after they were resolved, when that
|
||||
# comes first. Unset leaves them to FLUXER_REPORT_RETENTION_DAYS.
|
||||
#FLUXER_RESOLVED_REPORT_RETENTION_DAYS=
|
||||
# true only logs what the job would delete. Deleted evidence cannot be
|
||||
# restored, so check the "Processed report retention" log line of the worker
|
||||
# in a dry run first if you are unsure.
|
||||
#FLUXER_REPORT_RETENTION_DRY_RUN=false
|
||||
|
||||
# Sign in with Bluesky, off unless turned on.
|
||||
#FLUXER_AUTH_BLUESKY_ENABLED=false
|
||||
#FLUXER_AUTH_BLUESKY_CLIENT_NAME=Fluxer
|
||||
|
||||
@@ -132,11 +132,6 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
|
||||
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
|
||||
|
||||
FLUXER_NCMEC_ENABLED: ${FLUXER_NCMEC_ENABLED:-}
|
||||
FLUXER_NCMEC_BASE_URL: ${FLUXER_NCMEC_BASE_URL:-}
|
||||
FLUXER_NCMEC_USERNAME: ${FLUXER_NCMEC_USERNAME:-}
|
||||
FLUXER_NCMEC_PASSWORD: ${FLUXER_NCMEC_PASSWORD:-}
|
||||
FLUXER_NCMEC_REPORTER_EMAIL: ${FLUXER_NCMEC_REPORTER_EMAIL:-}
|
||||
FLUXER_CLAMAV_ENABLED: ${FLUXER_CLAMAV_ENABLED:-}
|
||||
FLUXER_CLAMAV_HOST: ${FLUXER_CLAMAV_HOST:-}
|
||||
FLUXER_CLAMAV_PORT: ${FLUXER_CLAMAV_PORT:-}
|
||||
@@ -158,6 +153,9 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
|
||||
FLUXER_REPORT_RETENTION_DAYS: ${FLUXER_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ${FLUXER_RESOLVED_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_REPORT_RETENTION_DRY_RUN: ${FLUXER_REPORT_RETENTION_DRY_RUN:-}
|
||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
|
||||
|
||||
FLUXER_AUTH_BLUESKY_ENABLED: ${FLUXER_AUTH_BLUESKY_ENABLED:-}
|
||||
|
||||
@@ -11,7 +11,6 @@ anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
base64 = "0.23.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde"] }
|
||||
cookie = "0.18.2"
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
hmac = "0.13.0"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
|
||||
@@ -175,7 +175,13 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &[
|
||||
"ChannelType",
|
||||
"MessageType",
|
||||
"ReportStatus",
|
||||
"ReportType",
|
||||
"WebhookType",
|
||||
];
|
||||
|
||||
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
|
||||
+1361
-372
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,6 @@ pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
|
||||
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
|
||||
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
|
||||
pub const CSAM_SUBMIT_NCMEC: &str = "csam:submit_ncmec";
|
||||
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
|
||||
pub const DISCOVERY_REVIEW: &str = "discovery:review";
|
||||
pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats";
|
||||
@@ -71,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
|
||||
pub const MESSAGE_DELETE: &str = "message:delete";
|
||||
pub const MESSAGE_LOOKUP: &str = "message:lookup";
|
||||
pub const MESSAGE_SHRED: &str = "message:shred";
|
||||
pub const REPORT_DELETE: &str = "report:delete";
|
||||
pub const REPORT_RESOLVE: &str = "report:resolve";
|
||||
pub const REPORT_VIEW: &str = "report:view";
|
||||
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
||||
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
|
||||
pub const USER_VIEW_EMAIL: &str = "user:view:email";
|
||||
pub const USER_VIEW_IP: &str = "user:view:ip";
|
||||
pub const USER_TEMP_BAN: &str = "user:temp_ban";
|
||||
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
|
||||
pub const USER_UPDATE_DOB: &str = "user:update:dob";
|
||||
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
|
||||
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
|
||||
@@ -149,7 +150,6 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BULK_DELETE_USER_MESSAGES,
|
||||
BULK_UPDATE_GUILD_FEATURES,
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
CSAM_SUBMIT_NCMEC,
|
||||
DISCOVERY_REMOVE,
|
||||
DISCOVERY_REVIEW,
|
||||
GATEWAY_MEMORY_STATS,
|
||||
@@ -177,6 +177,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
MESSAGE_DELETE,
|
||||
MESSAGE_LOOKUP,
|
||||
MESSAGE_SHRED,
|
||||
REPORT_DELETE,
|
||||
REPORT_RESOLVE,
|
||||
REPORT_VIEW,
|
||||
REPORT_VIEW_REPORTER_PII,
|
||||
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
USER_VIEW_EMAIL,
|
||||
USER_VIEW_IP,
|
||||
USER_TEMP_BAN,
|
||||
USER_UPDATE_BOT_STATUS,
|
||||
USER_UPDATE_DOB,
|
||||
USER_UPDATE_EMAIL,
|
||||
USER_UPDATE_FLAGS,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod user_flag_bits {
|
||||
pub const FRIENDLY_BOT: u64 = 1 << 4;
|
||||
pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5;
|
||||
pub const SPAMMER: u64 = 1 << 6;
|
||||
pub const PROFILE_HIDDEN: u64 = 1 << 7;
|
||||
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
|
||||
pub const DELETED: u64 = 1 << 34;
|
||||
pub const SELF_DELETED: u64 = 1 << 36;
|
||||
@@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
name: "SPAMMER",
|
||||
value: user_flag_bits::SPAMMER,
|
||||
},
|
||||
U64Flag {
|
||||
name: "PROFILE_HIDDEN",
|
||||
value: user_flag_bits::PROFILE_HIDDEN,
|
||||
},
|
||||
U64Flag {
|
||||
name: "HIGH_GLOBAL_RATE_LIMIT",
|
||||
value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT,
|
||||
@@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
value: 1 << 8,
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::USER_FLAGS;
|
||||
|
||||
#[test]
|
||||
fn user_flags_cover_every_flag_in_the_admin_spec() {
|
||||
let spec: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
|
||||
let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"]
|
||||
.as_array()
|
||||
.expect("UserFlags bitflag values");
|
||||
assert!(!values.is_empty());
|
||||
for entry in values {
|
||||
let name = entry["name"].as_str().expect("flag name");
|
||||
let value: u64 = entry["value"]
|
||||
.as_str()
|
||||
.expect("flag value")
|
||||
.parse()
|
||||
.expect("numeric flag value");
|
||||
assert!(
|
||||
USER_FLAGS
|
||||
.iter()
|
||||
.any(|flag| flag.name == name && flag.value == value),
|
||||
"{name} ({value}) is missing from USER_FLAGS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +337,9 @@ fn guild_update_response(
|
||||
.map_err(ApiError::Parse)?,
|
||||
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.map(String::from),
|
||||
nsfw: None,
|
||||
content_warning_level: None,
|
||||
content_warning_text: None,
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
use super::types::{CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
pub struct ListJobsParams {
|
||||
pub limit: u32,
|
||||
@@ -59,15 +59,6 @@ impl AdminApiClient {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_active_jobs()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
|
||||
|
||||
@@ -5,8 +5,7 @@ use crate::api::generated::{snowflake, types as generated_types};
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, NcmecAttachmentSubmitResult,
|
||||
SearchChannelMessagesResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -30,37 +29,6 @@ impl AdminApiClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn report_attachment_to_ncmec(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
attachment_id: &str,
|
||||
filename: &str,
|
||||
reporter_full_name: &str,
|
||||
source_report_id: Option<&str>,
|
||||
) -> ApiResult<NcmecAttachmentSubmitResult> {
|
||||
let body = generated_types::ReportAttachmentToNcmecRequest {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
confirmed_viewed: true,
|
||||
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(
|
||||
reporter_full_name,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
source_report_id: source_report_id.map(snowflake),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.create_admin_ncmec_report(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn lookup_message(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
ReportEntry, ReportLegalHoldResponse, ReportReasonListResponse, ResolveReportResponse,
|
||||
SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -13,8 +14,10 @@ pub struct SearchReportsParams<'a> {
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_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>,
|
||||
@@ -26,23 +29,6 @@ pub struct SearchReportsParams<'a> {
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
status: Option<i32>,
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -55,11 +41,12 @@ impl AdminApiClient {
|
||||
pub async fn resolve_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
resolution: UpdateReportRequestResolution,
|
||||
public_comment: Option<&str>,
|
||||
notify_reporter: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let mut body = serde_json::json!({"status": "resolved"});
|
||||
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
@@ -72,6 +59,40 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_report_legal_hold(
|
||||
&self,
|
||||
report_id: &str,
|
||||
legal_hold_until: Option<&str>,
|
||||
legal_hold_reason: Option<&str>,
|
||||
) -> ApiResult<ReportLegalHoldResponse> {
|
||||
let body = serde_json::json!({
|
||||
"legal_hold_until": legal_hold_until,
|
||||
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
|
||||
});
|
||||
self.post_with_reason(
|
||||
&format!(
|
||||
"/admin/reports/{}/legal-hold",
|
||||
urlencoding::encode(report_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
@@ -98,11 +119,16 @@ impl AdminApiClient {
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reason", params.reason.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_webhook_id",
|
||||
params.reported_webhook_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
@@ -127,6 +153,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn list_report_reasons(&self) -> ApiResult<ReportReasonListResponse> {
|
||||
self.get("/admin/report-reasons", None).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
&self,
|
||||
reporter_id: &str,
|
||||
|
||||
@@ -27,12 +27,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub plutonium_page: PlutoniumPageConfigResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub channel_threads: ChannelThreadsConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
@@ -421,6 +417,8 @@ pub struct AppSetupConfigResponse {
|
||||
pub struct AppLegalConfigResponse {
|
||||
pub terms_url: Option<String>,
|
||||
pub privacy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub guidelines_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -509,11 +507,8 @@ impl VoiceE2eeScope {
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
|
||||
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
|
||||
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
|
||||
pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1";
|
||||
pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1";
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -583,52 +578,6 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PlutoniumPageConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for PlutoniumPageConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PlutoniumPageConfigUpdateRequest {
|
||||
#[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 included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CaptchaConfigResponse {
|
||||
@@ -657,62 +606,6 @@ pub struct CaptchaConfigUpdateRequest {
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ChannelThreadsConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub ever_enabled: bool,
|
||||
pub guild_basis_points: u32,
|
||||
pub guild_salt: String,
|
||||
pub enabled_guild_ids: Vec<String>,
|
||||
pub disabled_guild_ids: Vec<String>,
|
||||
pub user_basis_points: u32,
|
||||
pub user_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ChannelThreadsConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
ever_enabled: false,
|
||||
guild_basis_points: 0,
|
||||
guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(),
|
||||
enabled_guild_ids: Vec::new(),
|
||||
disabled_guild_ids: Vec::new(),
|
||||
user_basis_points: 0,
|
||||
user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ChannelThreadsConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_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 {
|
||||
@@ -831,12 +724,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub channel_threads: Option<ChannelThreadsConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing: Option<InstanceBillingUpdateRequest>,
|
||||
@@ -1040,6 +929,8 @@ pub struct AppLegalConfigUpdateRequest {
|
||||
pub terms_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub privacy_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guidelines_url: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1137,24 +1028,17 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
|
||||
.expect("default plutonium page config");
|
||||
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
|
||||
.expect("default captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let plutonium_page =
|
||||
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
|
||||
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
|
||||
serde_json::from_value(plutonium_page.clone())
|
||||
.expect("generated plutonium page config contract");
|
||||
let generated_captcha: generated_types::CaptchaConfigResponse =
|
||||
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
@@ -1164,11 +1048,6 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_plutonium_page)
|
||||
.expect("serializable generated plutonium page config"),
|
||||
plutonium_page
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
captcha
|
||||
@@ -1180,7 +1059,6 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("PlutoniumPageConfigResponse", plutonium_page),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
@@ -1215,25 +1093,4 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = PlutoniumPageConfigUpdateRequest {
|
||||
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::PlutoniumPageConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,3 @@ pub struct GetJobResponse {
|
||||
pub struct CancelJobResponse {
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ActiveJobsResponse {
|
||||
pub jobs: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -34,12 +34,6 @@ pub struct DeleteAllUserMessagesResponse {
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct NcmecAttachmentSubmitResult {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BrowseChannelResponse {
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -98,6 +98,38 @@ pub struct ReportEntry {
|
||||
pub reported_user_global_name: Option<String>,
|
||||
pub reported_user_discriminator: Option<String>,
|
||||
pub reported_user_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_type: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_application_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_channel_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_avatar_hash: Option<String>,
|
||||
pub reported_guild_id: Option<String>,
|
||||
pub reported_guild_name: Option<String>,
|
||||
pub reported_guild_icon_hash: Option<String>,
|
||||
@@ -121,6 +153,148 @@ pub struct ReportEntry {
|
||||
pub public_comment: Option<String>,
|
||||
pub mutual_dm_channel_id: Option<String>,
|
||||
pub message_context: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_highest_priority: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub flow: Option<ReportFlowAnswersEntry>,
|
||||
#[serde(default)]
|
||||
pub reporter_good_faith_confirmed: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_user_bot: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_profile_snapshot: Option<ReportProfileSnapshot>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshot {
|
||||
#[serde(default)]
|
||||
pub captured_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user: Option<ReportProfileSnapshotUser>,
|
||||
#[serde(default)]
|
||||
pub member: Option<ReportProfileSnapshotMember>,
|
||||
#[serde(default)]
|
||||
pub guild: Option<ReportProfileSnapshotGuild>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotUser {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotMember {
|
||||
pub guild_id: String,
|
||||
#[serde(default)]
|
||||
pub nick: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub joined_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotGuild {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vanity_url_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub splash: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotAsset {
|
||||
pub hash: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportLegalHoldResponse {
|
||||
pub report_id: String,
|
||||
pub legal_hold_until: Option<String>,
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswersEntry {
|
||||
pub revision_hash: String,
|
||||
pub surface: String,
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub steps: Vec<ReportFlowAnswerStepEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerStepEntry {
|
||||
pub screen_id: String,
|
||||
pub screen_title: String,
|
||||
#[serde(default)]
|
||||
pub option_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub option_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ReportFlowAnswerItemEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerItemEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonEntry {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub highest_priority: bool,
|
||||
#[serde(default)]
|
||||
pub legacy_category_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_guild: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonListResponse {
|
||||
pub reasons: Vec<ReportReasonEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -131,11 +305,6 @@ pub struct SearchReportsResponse {
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ListReportsResponse {
|
||||
pub reports: Vec<ReportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResolveReportResponse {
|
||||
pub report_id: String,
|
||||
|
||||
@@ -306,6 +306,28 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn change_username(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::config::{
|
||||
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
|
||||
env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
|
||||
read_first_env, trim_trailing_slash,
|
||||
};
|
||||
|
||||
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
|
||||
const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminConfig {
|
||||
@@ -17,6 +18,7 @@ pub struct AdminConfig {
|
||||
pub api_endpoint: String,
|
||||
pub media_endpoint: String,
|
||||
pub static_cdn_endpoint: String,
|
||||
pub reports_bucket_origin: String,
|
||||
pub admin_endpoint: String,
|
||||
pub web_app_endpoint: String,
|
||||
pub oauth_client_id: String,
|
||||
@@ -76,6 +78,7 @@ impl AdminConfig {
|
||||
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
|
||||
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
|
||||
)),
|
||||
reports_bucket_origin: reports_bucket_origin_from_env(),
|
||||
|
||||
admin_endpoint,
|
||||
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
@@ -120,6 +123,72 @@ impl AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn reports_bucket_origin_from_env() -> String {
|
||||
let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT")
|
||||
.map(|value| normalize_public_endpoint_from_env(value.trim()));
|
||||
let endpoint = env_value("FLUXER_S3_ENDPOINT");
|
||||
presign_endpoint(
|
||||
public_endpoint.as_deref(),
|
||||
endpoint.as_deref(),
|
||||
&read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"),
|
||||
)
|
||||
.and_then(|endpoint| {
|
||||
bucket_origin(
|
||||
&endpoint,
|
||||
read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false),
|
||||
&read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned())
|
||||
}
|
||||
|
||||
fn presign_endpoint(
|
||||
public_endpoint: Option<&str>,
|
||||
endpoint: Option<&str>,
|
||||
uploads_bucket: &str,
|
||||
) -> Option<url::Url> {
|
||||
let Some(public_endpoint) = public_endpoint else {
|
||||
return url::Url::parse(endpoint?.trim()).ok();
|
||||
};
|
||||
let mut parsed = url::Url::parse(public_endpoint).ok()?;
|
||||
let host = parsed.host_str()?.to_owned();
|
||||
if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) {
|
||||
parsed.set_host(Some(shared_host)).ok()?;
|
||||
}
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option<String> {
|
||||
if !matches!(endpoint.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let path_style = force_path_style
|
||||
|| !matches!(endpoint.host(), Some(url::Host::Domain(_)))
|
||||
|| !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http");
|
||||
if path_style {
|
||||
return Some(endpoint.origin().ascii_serialization());
|
||||
}
|
||||
let mut virtual_host = endpoint.clone();
|
||||
virtual_host
|
||||
.set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?)))
|
||||
.ok()?;
|
||||
Some(virtual_host.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
|
||||
if allow_dots && bucket.contains('.') {
|
||||
return bucket
|
||||
.split('.')
|
||||
.all(|label| is_virtual_hostable_bucket(label, false));
|
||||
}
|
||||
(3..=63).contains(&bucket.len())
|
||||
&& bucket
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !bucket.starts_with('-')
|
||||
&& !bucket.ends_with('-')
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
pub(crate) fn from_env_value(value: &str) -> Self {
|
||||
match value {
|
||||
@@ -138,7 +207,7 @@ mod tests {
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 10] = [
|
||||
const MANAGED_ENV: [&str; 15] = [
|
||||
"FLUXER_ENV",
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
@@ -149,6 +218,11 @@ mod tests {
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
"FLUXER_BASE_DOMAIN",
|
||||
"FLUXER_S3_ENDPOINT",
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"FLUXER_S3_FORCE_PATH_STYLE",
|
||||
"FLUXER_S3_BUCKET_UPLOADS",
|
||||
"FLUXER_S3_BUCKET_REPORTS",
|
||||
];
|
||||
|
||||
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
|
||||
@@ -238,6 +312,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
@@ -266,6 +341,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
@@ -368,4 +444,237 @@ mod tests {
|
||||
format!("{api_admin_endpoint}/oauth2_callback")
|
||||
);
|
||||
}
|
||||
|
||||
fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option<String> {
|
||||
bucket_origin(
|
||||
&url::Url::parse(endpoint).expect("valid endpoint"),
|
||||
force_path_style,
|
||||
bucket,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_origin_matches_the_addressing_of_presigned_urls() {
|
||||
let cases = [
|
||||
(
|
||||
"https://ewr1.vultrobjects.com",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.ewr1.vultrobjects.com",
|
||||
),
|
||||
(
|
||||
"https://ewr1.vultrobjects.com/",
|
||||
true,
|
||||
"fluxer-reports",
|
||||
"https://ewr1.vultrobjects.com",
|
||||
),
|
||||
(
|
||||
"http://seaweedfs:8333",
|
||||
true,
|
||||
"fluxer-reports",
|
||||
"http://seaweedfs:8333",
|
||||
),
|
||||
(
|
||||
"http://seaweedfs:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://fluxer-reports.seaweedfs:8333",
|
||||
),
|
||||
(
|
||||
"http://127.0.0.1:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://127.0.0.1:8333",
|
||||
),
|
||||
(
|
||||
"http://[::1]:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://[::1]:8333",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com:9000",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com:9000",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com:443/base/path",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://S3.Example.com",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"reports.example",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"http://s3.example.com",
|
||||
false,
|
||||
"reports.example",
|
||||
"http://reports.example.s3.example.com",
|
||||
),
|
||||
(
|
||||
"http://s3.example.com",
|
||||
false,
|
||||
"a.example",
|
||||
"http://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"Reports",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"ab",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"reports_bucket",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"-reports",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"192.168.1.1",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
];
|
||||
for (endpoint, force_path_style, bucket, expected) in cases {
|
||||
assert_eq!(
|
||||
origin_for(endpoint, force_path_style, bucket).as_deref(),
|
||||
Some(expected),
|
||||
"{endpoint} {force_path_style} {bucket}"
|
||||
);
|
||||
}
|
||||
assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() {
|
||||
let host = |public: Option<&str>, endpoint: Option<&str>| {
|
||||
presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string())
|
||||
};
|
||||
assert_eq!(
|
||||
host(
|
||||
Some("https://fluxer-uploads.ewr1.vultrobjects.com"),
|
||||
Some("https://internal.example")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://ewr1.vultrobjects.com/")
|
||||
);
|
||||
assert_eq!(
|
||||
host(
|
||||
Some("https://cdn.example.com"),
|
||||
Some("http://seaweedfs:8333")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://cdn.example.com/")
|
||||
);
|
||||
assert_eq!(
|
||||
host(None, Some("http://seaweedfs:8333")).as_deref(),
|
||||
Some("http://seaweedfs:8333/")
|
||||
);
|
||||
assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None);
|
||||
assert_eq!(host(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() {
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
|
||||
let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]);
|
||||
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reports_bucket_origin_follows_the_object_store_settings() {
|
||||
let hosted = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://fluxer-uploads.ewr1.vultrobjects.com",
|
||||
),
|
||||
]);
|
||||
assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
|
||||
let bundled = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://objects.fluxer.example",
|
||||
),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(
|
||||
bundled.reports_bucket_origin,
|
||||
"https://objects.fluxer.example"
|
||||
);
|
||||
|
||||
let internal_only = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333");
|
||||
|
||||
let outside = config_from_env(&[
|
||||
(
|
||||
"FLUXER_S3_ENDPOINT",
|
||||
"https://s3.eu-central-1.amazonaws.com",
|
||||
),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
|
||||
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
|
||||
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
|
||||
]);
|
||||
assert_eq!(
|
||||
outside.reports_bucket_origin,
|
||||
"https://example-reports.s3.eu-central-1.amazonaws.com"
|
||||
);
|
||||
|
||||
let renamed_uploads = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "https://s3.example.com"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://example-uploads.s3.example.com",
|
||||
),
|
||||
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
|
||||
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
|
||||
]);
|
||||
assert_eq!(
|
||||
renamed_uploads.reports_bucket_origin,
|
||||
"https://example-reports.s3.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_reports_bucket_origin() {
|
||||
let config = config_from_env(&[
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
middleware::flash::{self, FlashData},
|
||||
middleware::flash,
|
||||
session::{self, Session},
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -65,10 +64,6 @@ pub async fn require_auth(
|
||||
response
|
||||
}
|
||||
|
||||
pub fn get_flash(request: &Request) -> Option<FlashData> {
|
||||
request.extensions().get::<FlashData>().cloned()
|
||||
}
|
||||
|
||||
fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
@@ -159,11 +154,3 @@ async fn fetch_admin_user(
|
||||
Err(_) => AdminFetchResult::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_auth_context(request: &Request) -> Option<&AuthContext> {
|
||||
request.extensions().get::<AuthContext>()
|
||||
}
|
||||
|
||||
pub fn require_auth_context(request: &Request) -> Result<&AuthContext, Box<Response>> {
|
||||
get_auth_context(request).ok_or_else(|| Box::new(StatusCode::UNAUTHORIZED.into_response()))
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use maud::{DOCTYPE, html};
|
||||
|
||||
pub struct AppError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
pub detail: Option<String>,
|
||||
pub home_url: String,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn not_found(message: &str, base_path: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: if base_path.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
base_path.to_owned()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_detail(mut self, detail: String) -> Self {
|
||||
self.detail = Some(detail);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_base_path(mut self, base_path: &str) -> Self {
|
||||
self.home_url = if base_path.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
base_path.to_owned()
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status_code = self.status.as_u16();
|
||||
let status_text = self.status.canonical_reason().unwrap_or("Error");
|
||||
let home_url = &self.home_url;
|
||||
let (border, bg, text_color) = if status_code >= 500 {
|
||||
("border-red-300", "bg-red-50", "text-red-800")
|
||||
} else if status_code == 403 {
|
||||
("border-yellow-300", "bg-yellow-50", "text-yellow-800")
|
||||
} else {
|
||||
("border-neutral-300", "bg-neutral-50", "text-neutral-800")
|
||||
};
|
||||
let markup = html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="UTF-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
title { (status_code) " " (status_text) " ~ Fluxer Admin" }
|
||||
}
|
||||
body class="min-h-screen bg-neutral-50 flex items-center justify-center" {
|
||||
div class="mx-auto max-w-lg px-4 py-16 text-center" {
|
||||
h1 class="text-6xl font-bold text-neutral-300 mb-4" {
|
||||
(status_code)
|
||||
}
|
||||
h2 class="text-xl font-semibold text-neutral-700 mb-2" {
|
||||
(status_text)
|
||||
}
|
||||
div class={"rounded-lg border px-4 py-3 text-sm mb-6 " (border) " " (bg) " " (text_color)} {
|
||||
div { (self.message) }
|
||||
@if let Some(ref detail) = self.detail {
|
||||
div class="mt-2 break-all rounded border border-current/20 \
|
||||
bg-white/60 px-3 py-2 text-xs" {
|
||||
(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
a href=(home_url)
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline text-sm" {
|
||||
"Go to admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(self.status, Html(markup.into_string())).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
tracing::error!(?err, "internal server error");
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: "An internal error occurred.".to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,16 +93,20 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
set_flash_cookie(&mut response, &flash, secure);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool) {
|
||||
let encoded = serialize_flash(flash);
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
|
||||
const HX_RESWAP: HeaderName = HeaderName::from_static("hx-reswap");
|
||||
const ADMIN_TOAST: HeaderName = HeaderName::from_static("x-fluxer-admin-toast");
|
||||
const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect");
|
||||
|
||||
pub fn is_htmx_request(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
@@ -18,13 +19,6 @@ pub fn is_htmx_request(headers: &HeaderMap) -> bool {
|
||||
.is_some_and(|value| value == "true")
|
||||
}
|
||||
|
||||
pub fn htmx_current_url(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("HX-Current-URL")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
pub fn htmx_target(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("HX-Target")
|
||||
@@ -45,6 +39,16 @@ pub fn toast_response(flash: &FlashData) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
pub fn navigate_with_flash(url: &str, flash: &FlashData, secure: bool) -> Response {
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(url) {
|
||||
response.headers_mut().insert(HX_REDIRECT, value);
|
||||
}
|
||||
add_toast_header(&mut response, flash);
|
||||
flash::set_flash_cookie(&mut response, flash, secure);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn add_toast_header(response: &mut Response, flash: &FlashData) {
|
||||
let payload = serde_json::json!({
|
||||
"level": flash.flash_type,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
pub mod account_identity;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod error_handler;
|
||||
pub mod flash;
|
||||
pub mod htmx;
|
||||
pub mod self_hosted;
|
||||
|
||||
@@ -9,17 +9,11 @@ pub const ADMIN_OAUTH_SCOPE: &str = "identify email";
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub discriminator: String,
|
||||
pub avatar: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub global_name: Option<String>,
|
||||
}
|
||||
|
||||
pub fn authorize_url(config: &AdminConfig, state: &str) -> String {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::api::client::{AdminApiClient, ApiResult};
|
||||
use crate::api::types::{FlashLevel, FlashMessage};
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::templates;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -201,7 +202,13 @@ async fn execute_check(
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
|
||||
Some(expires_at) => (
|
||||
"info",
|
||||
format!(
|
||||
"{value} is banned until {}",
|
||||
format_admin_timestamp(&expires_at)
|
||||
),
|
||||
),
|
||||
None => ("info", format!("{value} is banned")),
|
||||
},
|
||||
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||
|
||||
@@ -106,20 +106,11 @@ pub async fn render(
|
||||
.map(|response| response.threads)
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
|
||||
.unwrap_or_default();
|
||||
let threads_enabled = client
|
||||
.get_instance_config()
|
||||
.await
|
||||
.map(|instance| instance.channel_threads.enabled)
|
||||
.map_err(
|
||||
|error| tracing::warn!(%error, "admin API request failed: get instance config"),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
Some(tabs::threads::threads_tab(
|
||||
config,
|
||||
&guild,
|
||||
&threads,
|
||||
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
|
||||
threads_enabled,
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
templates,
|
||||
};
|
||||
use axum::{
|
||||
Form, Json, Router,
|
||||
Form, Router,
|
||||
extract::{FromRequest, Path, Query, Request, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
@@ -46,7 +46,6 @@ struct JobActionForm {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/jobs", get(jobs_list))
|
||||
.route("/jobs/active.json", get(jobs_active_json))
|
||||
.route("/jobs/{job_id}", get(job_detail).post(job_detail_post))
|
||||
}
|
||||
|
||||
@@ -128,21 +127,6 @@ async fn jobs_list(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn jobs_active_json(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
match client.list_active_jobs().await {
|
||||
Ok(data) => Json(serde_json::json!(data)).into_response(),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list active jobs");
|
||||
Json(serde_json::json!({ "jobs": [] })).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn job_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -105,45 +105,6 @@ pub(crate) async fn messages_post(
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
}
|
||||
}
|
||||
"report-to-ncmec" => {
|
||||
let attachment_id = form.clean("attachment_id");
|
||||
let filename = form.clean("filename");
|
||||
let reporter_full_name = form.clean("reporter_full_name");
|
||||
let source_report_id = form.clean("source_report_id");
|
||||
let confirmed_viewed = form.bool_value("confirmed_viewed");
|
||||
let (Some(cid), Some(mid), Some(aid), Some(name), Some(reporter)) = (
|
||||
&channel_id,
|
||||
&message_id,
|
||||
&attachment_id,
|
||||
&filename,
|
||||
&reporter_full_name,
|
||||
) else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing required NCMEC report fields",
|
||||
);
|
||||
};
|
||||
if !confirmed_viewed {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing required NCMEC report fields",
|
||||
);
|
||||
}
|
||||
match client
|
||||
.report_attachment_to_ncmec(
|
||||
cid,
|
||||
mid,
|
||||
aid,
|
||||
name,
|
||||
reporter,
|
||||
source_report_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => Json(resp.data).into_response(),
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
}
|
||||
}
|
||||
_ => Redirect::to(&format!("{base}/messages")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String {
|
||||
"default-src 'self'".to_owned(),
|
||||
"script-src 'self' 'unsafe-inline'".to_owned(),
|
||||
"style-src 'self' 'unsafe-inline'".to_owned(),
|
||||
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
|
||||
format!(
|
||||
"img-src 'self' data: blob: {static_cdn} {media} {}",
|
||||
config.reports_bucket_origin
|
||||
),
|
||||
"font-src 'self'".to_owned(),
|
||||
"connect-src 'self'".to_owned(),
|
||||
"object-src 'none'".to_owned(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
client::{AdminApiClient, ApiError, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
types::ReportEntry,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
@@ -14,13 +16,12 @@ use crate::{
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
utils::forms::clean_string,
|
||||
utils::{forms::clean_string, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::{FromRequest, Path, Query, Request, State},
|
||||
http::HeaderMap,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
@@ -35,8 +36,10 @@ struct ReportsQuery {
|
||||
#[serde(rename = "type")]
|
||||
report_type: Option<String>,
|
||||
category: Option<String>,
|
||||
reason: Option<String>,
|
||||
reporter_id: Option<String>,
|
||||
reported_user_id: Option<String>,
|
||||
reported_webhook_id: Option<String>,
|
||||
reported_guild_id: Option<String>,
|
||||
reported_channel_id: Option<String>,
|
||||
guild_context_id: Option<String>,
|
||||
@@ -46,6 +49,28 @@ struct ReportsQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegalHoldForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
confirm: Option<String>,
|
||||
#[serde(default)]
|
||||
audit_log_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveForm {
|
||||
#[serde(default)]
|
||||
@@ -53,6 +78,8 @@ struct ResolveForm {
|
||||
#[serde(default)]
|
||||
resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
public_comment: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter_present: Option<String>,
|
||||
@@ -67,6 +94,14 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/resolve",
|
||||
axum::routing::post(report_resolve),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/delete",
|
||||
axum::routing::post(report_delete),
|
||||
)
|
||||
}
|
||||
|
||||
async fn reports_list(
|
||||
@@ -95,25 +130,30 @@ async fn reports_list(
|
||||
.report_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
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");
|
||||
let reason = query.reason.as_deref().and_then(clean_string);
|
||||
let params = SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_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,
|
||||
};
|
||||
let (reports, reasons) = tokio::join!(
|
||||
client.search_reports(¶ms),
|
||||
state.report_reasons(&client)
|
||||
);
|
||||
let reports = reports.log_error("search reports");
|
||||
|
||||
let markup = templates::pages::reports_list::reports_list_page(
|
||||
config,
|
||||
@@ -124,14 +164,17 @@ async fn reports_list(
|
||||
status: query.status.as_deref(),
|
||||
report_type: query.report_type.as_deref(),
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_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: query.sort.as_deref().unwrap_or("reportedAt_desc"),
|
||||
},
|
||||
reasons.as_deref(),
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
@@ -167,10 +210,12 @@ async fn report_detail(
|
||||
.log_error("load report detail");
|
||||
match report {
|
||||
Some(report) => {
|
||||
let live = load_live_profile(&client, &auth.0, &report).await;
|
||||
let markup = templates::pages::report_detail::report_detail_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&report,
|
||||
&live,
|
||||
&csrf_token,
|
||||
is_detail_fragment,
|
||||
);
|
||||
@@ -183,6 +228,49 @@ async fn report_detail(
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_live_profile(
|
||||
client: &AdminApiClient,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
) -> templates::pages::report_detail::LiveProfile {
|
||||
let can = |permission: &str| {
|
||||
auth.admin_user
|
||||
.as_ref()
|
||||
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
|
||||
};
|
||||
let snapshot = report.reported_profile_snapshot.as_ref();
|
||||
let user_id = snapshot
|
||||
.and_then(|snapshot| snapshot.user.as_ref())
|
||||
.map(|user| user.id.as_str())
|
||||
.filter(|_| can(acl::USER_LOOKUP));
|
||||
let guild_id = snapshot
|
||||
.and_then(|snapshot| snapshot.guild.as_ref())
|
||||
.map(|guild| guild.id.as_str())
|
||||
.filter(|_| can(acl::GUILD_LOOKUP));
|
||||
let (user, guild) = tokio::join!(
|
||||
async {
|
||||
match user_id {
|
||||
Some(id) => client
|
||||
.get_user_by_id(id)
|
||||
.await
|
||||
.log_error("load live reported user"),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
async {
|
||||
match guild_id {
|
||||
Some(id) => client
|
||||
.lookup_guild(id)
|
||||
.await
|
||||
.log_error("load live reported guild")
|
||||
.flatten(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
);
|
||||
templates::pages::report_detail::LiveProfile { user, guild }
|
||||
}
|
||||
|
||||
async fn report_fragment(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -214,50 +302,211 @@ async fn report_resolve(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let is_background = request
|
||||
.uri()
|
||||
.query()
|
||||
.is_some_and(|query| query.split('&').any(|part| part == "background=1"));
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let form: ResolveForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report resolve form");
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some((resolution, label)) =
|
||||
templates::pages::report_detail::resolution_choice(form.resolution.as_deref())
|
||||
else {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(RESOLUTION_REQUIRED),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
|
||||
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
|
||||
let notify_reporter =
|
||||
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
|
||||
let result = client
|
||||
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if is_background {
|
||||
return StatusCode::NO_CONTENT.into_response();
|
||||
}
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::success("Report resolved"),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
let flash = match client
|
||||
.resolve_report(
|
||||
&report_id,
|
||||
resolution,
|
||||
public_comment.as_deref(),
|
||||
notify_reporter,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => FlashData::success(format!("Report resolved: {label}")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: resolve report");
|
||||
if is_background {
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Failed to resolve report"),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
FlashData::error("Failed to resolve report")
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
|
||||
|
||||
async fn report_legal_hold(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let form: LegalHoldForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report legal hold form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let clearing = form.clear.is_some();
|
||||
let until = if clearing {
|
||||
None
|
||||
} else {
|
||||
match form.legal_hold_until.as_deref().and_then(legal_hold_date) {
|
||||
Some(date) if date < time::OffsetDateTime::now_utc().date() => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_IN_PAST),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
Some(date) => Some(legal_hold_end_of_day(date)),
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Choose the date the hold ends"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let reason = clean_string(form.legal_hold_reason.as_deref().unwrap_or(""));
|
||||
if until.is_some() && reason.is_none() {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_NEEDS_REASON),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client
|
||||
.set_report_legal_hold(&report_id, until.as_deref(), reason.as_deref())
|
||||
.await;
|
||||
let flash = match result {
|
||||
Ok(response) => match response.legal_hold_until {
|
||||
Some(until) => FlashData::success(format!(
|
||||
"Legal hold placed until {}",
|
||||
format_admin_timestamp(&until)
|
||||
)),
|
||||
None => FlashData::success("Legal hold cleared"),
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: set report legal hold");
|
||||
match error {
|
||||
ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
} => FlashData::error(legal_hold_refusal(&message)),
|
||||
_ => FlashData::error("Failed to update the legal hold"),
|
||||
}
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn report_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let navigates = htmx::is_htmx_request(request.headers())
|
||||
&& htmx::targets(request.headers(), "flash-container");
|
||||
let form: DeleteForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report delete form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if form.confirm.as_deref() != Some("true") {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(DELETE_NEEDS_CONFIRMATION),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let audit_log_reason = clean_string(form.audit_log_reason.as_deref().unwrap_or(""));
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
match client
|
||||
.delete_report(&report_id, audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let list = format!("{base}/reports");
|
||||
let flash = FlashData::success("Report deleted");
|
||||
if navigates {
|
||||
htmx::navigate_with_flash(&list, &flash, config.secure_cookies())
|
||||
} else {
|
||||
flash::redirect_with_flash(&list, flash, config.secure_cookies())
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: delete report");
|
||||
let message = match error {
|
||||
ApiError::Http { status: 409, .. } => DELETE_REFUSED_HELD,
|
||||
ApiError::Http { status: 404, .. } => "The report no longer exists",
|
||||
_ => "Failed to delete the report",
|
||||
};
|
||||
flash::redirect_with_flash(&back, FlashData::error(message), config.secure_cookies())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DELETE_NEEDS_CONFIRMATION: &str = "Confirm that the report should be deleted";
|
||||
const DELETE_REFUSED_HELD: &str =
|
||||
"The report is under a legal hold. Clear the hold before deleting it.";
|
||||
|
||||
const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future";
|
||||
const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold";
|
||||
|
||||
fn legal_hold_date(date: &str) -> Option<time::Date> {
|
||||
let format = time::format_description::parse_borrowed::<2>("[year]-[month]-[day]").ok()?;
|
||||
time::Date::parse(date.trim(), &format).ok()
|
||||
}
|
||||
|
||||
fn legal_hold_end_of_day(date: time::Date) -> String {
|
||||
format!("{date}T23:59:59.999Z")
|
||||
}
|
||||
|
||||
fn legal_hold_refusal(body: &str) -> &'static str {
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
let reason_refused = body["errors"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|error| error["path"] == "legal_hold_reason");
|
||||
if reason_refused {
|
||||
LEGAL_HOLD_NEEDS_REASON
|
||||
} else {
|
||||
LEGAL_HOLD_IN_PAST
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,3 +520,51 @@ fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
|
||||
_ => ("reportedAt", "desc"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legal_hold_dates_end_at_the_end_of_the_day_in_utc() {
|
||||
assert_eq!(
|
||||
legal_hold_date("2027-01-31")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-01-31T23:59:59.999Z")
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_date(" 2027-02-01 ")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-02-01T23:59:59.999Z")
|
||||
);
|
||||
for invalid in ["", "2027-02-30", "31/01/2027", "2027-1-31", "tomorrow"] {
|
||||
assert_eq!(legal_hold_date(invalid), None, "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_hold_names_the_field_the_api_refused() {
|
||||
let refusal = |path: &str| {
|
||||
serde_json::json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input Validation Error",
|
||||
"errors": [{"path": path, "message": "refused"}]
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_until")),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_reason")),
|
||||
"Give a reason for the hold"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal("not json"),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ pub fn router() -> Router<AppState> {
|
||||
"/limit-config",
|
||||
get(limit_config_page).post(system_actions::limit_config_post),
|
||||
)
|
||||
.route("/strange-place", get(strange_place_page))
|
||||
}
|
||||
|
||||
async fn gateway_page(
|
||||
@@ -263,15 +262,6 @@ async fn limit_config_page(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn strange_place_page(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let markup = templates::pages::strange_place::strange_place_page(config, &auth.0);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn nonempty(s: &str) -> Option<String> {
|
||||
if s.is_empty() {
|
||||
None
|
||||
|
||||
@@ -7,20 +7,19 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
|
||||
PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest,
|
||||
RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -221,19 +220,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_plutonium_page" => match build_plutonium_page_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_captcha" => match build_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => instance_config_result(
|
||||
client
|
||||
.update_instance_config(&build_channel_threads_update(&form))
|
||||
.await,
|
||||
),
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -619,41 +609,6 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_plutonium_page_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("plutonium_page_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"plutonium_page_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
captcha: Some(CaptchaConfigUpdateRequest {
|
||||
@@ -677,28 +632,6 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
|
||||
})
|
||||
}
|
||||
|
||||
fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let channel_threads = if form.bool_value("channel_threads_everyone") {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
disabled_guild_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(channel_threads),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -776,6 +709,7 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
legal: Some(AppLegalConfigUpdateRequest {
|
||||
terms_url: optional("app_terms_url"),
|
||||
privacy_url: optional("app_privacy_url"),
|
||||
guidelines_url: optional("app_guidelines_url"),
|
||||
}),
|
||||
registration: None,
|
||||
}),
|
||||
@@ -1225,6 +1159,47 @@ pub async fn limit_config_post(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_app_legal_update_sends_the_guidelines_url_and_clears_blank_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"app_terms_url=+https%3A%2F%2Fexample.com%2Fterms+&app_privacy_url=&app_guidelines_url=https%3A%2F%2Fexample.com%2Frules",
|
||||
);
|
||||
let request = build_app_legal_update(&form);
|
||||
let legal = request
|
||||
.app_public
|
||||
.expect("app public update")
|
||||
.legal
|
||||
.expect("legal update");
|
||||
assert_eq!(
|
||||
legal.terms_url,
|
||||
Some(Some("https://example.com/terms".to_owned()))
|
||||
);
|
||||
assert_eq!(legal.privacy_url, Some(None));
|
||||
assert_eq!(
|
||||
legal.guidelines_url,
|
||||
Some(Some("https://example.com/rules".to_owned()))
|
||||
);
|
||||
let body = serde_json::to_value(&legal).expect("serialize legal update");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"terms_url": "https://example.com/terms",
|
||||
"privacy_url": null,
|
||||
"guidelines_url": "https://example.com/rules"
|
||||
})
|
||||
);
|
||||
|
||||
let cleared = build_app_legal_update(&MultiValueForm::parse(
|
||||
b"app_terms_url=&app_privacy_url=&app_guidelines_url=+",
|
||||
));
|
||||
let body = serde_json::to_value(cleared.app_public.expect("app public").legal)
|
||||
.expect("serialize cleared legal update");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({"terms_url": null, "privacy_url": null, "guidelines_url": null})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_integrations_update_leaves_email_alone_when_its_fields_are_hidden() {
|
||||
let hidden = build_integrations_update(&MultiValueForm::parse(
|
||||
@@ -1464,30 +1439,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_turns_threads_on_for_everyone() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": true,
|
||||
"guild_basis_points": 10000,
|
||||
"user_basis_points": 10000,
|
||||
"disabled_guild_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {"enabled": false}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
@@ -1568,72 +1519,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
|
||||
);
|
||||
let update = build_plutonium_page_update(&form)
|
||||
.expect("valid form")
|
||||
.plutonium_page
|
||||
.expect("plutonium page update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_plutonium_page_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"plutonium_page": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"plutonium_page_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_plutonium_page_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
@@ -178,6 +179,22 @@ pub async fn dispatch(
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_bot_status(user_id, val).await,
|
||||
"Bot status updated successfully",
|
||||
"Failed to update bot status",
|
||||
)
|
||||
}
|
||||
"set_system_status" => {
|
||||
let val = form.bool_value("system");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_system_status(user_id, val).await,
|
||||
"System status updated successfully",
|
||||
"Failed to update system status",
|
||||
)
|
||||
}
|
||||
"change_username" => {
|
||||
let Some(username) = get("username") else {
|
||||
return DispatchOutcome::error("Username is required");
|
||||
@@ -295,7 +312,7 @@ pub async fn dispatch(
|
||||
};
|
||||
if !form.bool_value("confirm") {
|
||||
return DispatchOutcome::error(
|
||||
"Confirm whose deletion you are cancelling before submitting",
|
||||
"Confirm whose deletion you are canceling before submitting",
|
||||
);
|
||||
}
|
||||
let Some(private_reason) = get("private_reason") else {
|
||||
@@ -306,7 +323,7 @@ pub async fn dispatch(
|
||||
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
|
||||
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The pending deletion changed since this page loaded. Reload and review.",
|
||||
),
|
||||
@@ -419,8 +436,9 @@ pub async fn dispatch(
|
||||
match client.delete_all_user_messages(user_id, dry_run).await {
|
||||
Ok(response) if dry_run => DispatchOutcome {
|
||||
flash: FlashData::success(format!(
|
||||
"Dry run found {} messages across {} channels. Confirm to delete them permanently.",
|
||||
response.message_count, response.channel_count
|
||||
"Dry run found {} across {}. Confirm to delete them permanently.",
|
||||
count_noun(response.message_count, "message", "messages"),
|
||||
count_noun(response.channel_count, "channel", "channels")
|
||||
)),
|
||||
redirect_params: vec![
|
||||
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
|
||||
@@ -454,7 +472,7 @@ pub async fn dispatch(
|
||||
}
|
||||
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
|
||||
client.cancel_bulk_message_deletion(user_id).await,
|
||||
"Bulk message deletion cancelled successfully",
|
||||
"Bulk message deletion canceled successfully",
|
||||
"Failed to cancel bulk message deletion",
|
||||
),
|
||||
"message_shred" => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding, ReportReasonEntry},
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ struct AppStateInner {
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
|
||||
report_reasons: Mutex<Option<Arc<[ReportReasonEntry]>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -40,6 +41,7 @@ impl AppState {
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
account_identity: Mutex::new(None),
|
||||
report_reasons: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -126,6 +128,35 @@ impl AppState {
|
||||
Some((Instant::now() + ttl, settings));
|
||||
settings
|
||||
}
|
||||
|
||||
pub async fn report_reasons(
|
||||
&self,
|
||||
client: &AdminApiClient,
|
||||
) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
if let Some(reasons) = self.cached_report_reasons() {
|
||||
return Some(reasons);
|
||||
}
|
||||
let reasons: Arc<[ReportReasonEntry]> = client
|
||||
.list_report_reasons()
|
||||
.await
|
||||
.log_error("load report reasons")?
|
||||
.reasons
|
||||
.into();
|
||||
*self
|
||||
.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reasons.clone());
|
||||
Some(reasons)
|
||||
}
|
||||
|
||||
fn cached_report_reasons(&self) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
self.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 {
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
select,
|
||||
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) {
|
||||
height: auto;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
@@ -71,33 +77,6 @@ main:focus {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
border: 1px solid #86efac;
|
||||
background-color: #f0fdf4;
|
||||
color: #166534;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
border: 1px solid #fca5a5;
|
||||
background-color: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
border: 1px solid #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
inset: unset;
|
||||
margin: 0;
|
||||
@@ -109,8 +88,6 @@ main:focus {
|
||||
}
|
||||
|
||||
.drawer-panel[data-drawer-side="right"] { right: 0; top: 0; }
|
||||
.drawer-panel[data-drawer-side="left"] { left: 0; top: 0; }
|
||||
.drawer-panel[data-drawer-side="bottom"] { bottom: 0; left: 0; right: 0; }
|
||||
|
||||
.drawer-panel[data-open],
|
||||
.drawer-panel:popover-open {
|
||||
|
||||
@@ -5,16 +5,12 @@ use maud::{Markup, html};
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum AlertVariant {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Markup {
|
||||
let variant_classes = match variant {
|
||||
AlertVariant::Info => "bg-blue-50 border-blue-200 text-blue-700",
|
||||
AlertVariant::Warning => "bg-neutral-50 border-neutral-200 text-neutral-700",
|
||||
AlertVariant::Error => "bg-red-50 border-red-200 text-red-700",
|
||||
AlertVariant::Success => "bg-green-50 border-green-200 text-green-700",
|
||||
};
|
||||
html! {
|
||||
@@ -30,15 +26,3 @@ pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Mar
|
||||
pub fn alert_info(content: Markup) -> Markup {
|
||||
alert(AlertVariant::Info, None, content)
|
||||
}
|
||||
|
||||
pub fn alert_error(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Error, Some(title), content)
|
||||
}
|
||||
|
||||
pub fn alert_success(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Success, Some(title), content)
|
||||
}
|
||||
|
||||
pub fn alert_warning(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Warning, Some(title), content)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::Archive,
|
||||
templates::components::form::{checkbox, csrf_input, submit_button},
|
||||
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
|
||||
@for archive in archives {
|
||||
tr {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(archive.requested_at)
|
||||
(format_admin_timestamp(&archive.requested_at))
|
||||
}
|
||||
td class="px-4 py-3 text-sm text-neutral-900" {
|
||||
(status_text(archive))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::AuditLogEntry,
|
||||
templates::pages::audit_logs_table::{
|
||||
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
|
||||
(table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="text-sm whitespace-nowrap" {
|
||||
(log.created_at)
|
||||
(format_admin_timestamp(&log.created_at))
|
||||
}
|
||||
}))
|
||||
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
|
||||
|
||||
@@ -12,7 +12,3 @@ pub fn auto_refresh(enabled: bool, interval_ms: u32) -> Markup {
|
||||
script defer { (PreEscaped(script)) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auto_refresh_default(enabled: bool) -> Markup {
|
||||
auto_refresh(enabled, 3000)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,3 @@ pub fn badge(label: &str, variant: BadgeVariant) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag_badge(label: &str, active: bool) -> Markup {
|
||||
if active {
|
||||
badge(label, BadgeVariant::Success)
|
||||
} else {
|
||||
badge(label, BadgeVariant::Default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ pub fn data_field_mono(label: &str, value: &str) -> Markup {
|
||||
data_field(label, data_field_value(value, DATA_FIELD_MONO_CLASS))
|
||||
}
|
||||
|
||||
pub fn data_field_link(label: &str, href: &str, display: &str) -> Markup {
|
||||
data_field(label, data_field_link_value(href, display, "break-words"))
|
||||
}
|
||||
|
||||
pub fn data_field_link_mono(label: &str, href: &str, display: &str) -> Markup {
|
||||
data_field(label, data_field_link_value(href, display, "break-all"))
|
||||
}
|
||||
@@ -66,20 +62,3 @@ pub fn data_grid(cols: u8, content: Markup) -> Markup {
|
||||
div class=(col_class) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata_row(label: &str, value: Markup) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-wrap gap-x-2 gap-y-0.5" {
|
||||
span class="flex-shrink-0 text-neutral-500 text-sm" {
|
||||
(label) ":"
|
||||
}
|
||||
span class="min-w-0 break-words text-neutral-900 text-sm" {
|
||||
(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata_row_text(label: &str, value: &str) -> Markup {
|
||||
metadata_row(label, html! { (value) })
|
||||
}
|
||||
|
||||
@@ -7,22 +7,16 @@ use super::icons::{close_icon, spinner_icon};
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DrawerSide {
|
||||
Right,
|
||||
Left,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DrawerWidth {
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
Xl,
|
||||
}
|
||||
|
||||
fn width_class(width: DrawerWidth) -> &'static str {
|
||||
match width {
|
||||
DrawerWidth::Sm => "sm:max-w-sm",
|
||||
DrawerWidth::Md => "sm:max-w-md",
|
||||
DrawerWidth::Lg => "sm:max-w-lg",
|
||||
DrawerWidth::Xl => "sm:max-w-xl",
|
||||
}
|
||||
@@ -31,24 +25,18 @@ fn width_class(width: DrawerWidth) -> &'static str {
|
||||
fn side_class(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "right-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
|
||||
DrawerSide::Left => "left-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
|
||||
DrawerSide::Bottom => "bottom-0 left-0 right-0 max-h-[92dvh] w-full",
|
||||
}
|
||||
}
|
||||
|
||||
fn side_off_class(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "translate-x-full",
|
||||
DrawerSide::Left => "-translate-x-full",
|
||||
DrawerSide::Bottom => "translate-y-full",
|
||||
}
|
||||
}
|
||||
|
||||
fn side_name(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "right",
|
||||
DrawerSide::Left => "left",
|
||||
DrawerSide::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +56,7 @@ pub fn drawer(
|
||||
"drawer-panel pointer-events-auto fixed z-50 flex flex-col \
|
||||
bg-white shadow-2xl {} {} {}",
|
||||
side_class(side),
|
||||
match side {
|
||||
DrawerSide::Bottom => "",
|
||||
_ => width_class(width),
|
||||
},
|
||||
width_class(width),
|
||||
side_off_class(side),
|
||||
);
|
||||
html! {
|
||||
@@ -150,16 +135,6 @@ pub fn drawer_loading_state() -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drawer_error_state(message: &str) -> Markup {
|
||||
html! {
|
||||
div role="alert"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4 \
|
||||
text-red-800 text-sm" {
|
||||
(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drawer_controller_script() -> Markup {
|
||||
html! {
|
||||
script defer { (PreEscaped(DRAWER_CONTROLLER_SCRIPT)) }
|
||||
|
||||
@@ -2,25 +2,6 @@
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum EmptyStateVariant {
|
||||
Empty,
|
||||
Loading,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub fn empty_state(variant: EmptyStateVariant, content: Markup) -> Markup {
|
||||
let classes = match variant {
|
||||
EmptyStateVariant::Empty | EmptyStateVariant::Loading => {
|
||||
"text-neutral-500 text-center py-8"
|
||||
}
|
||||
EmptyStateVariant::Error => "text-red-600 text-center py-8",
|
||||
};
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_state_full(icon: Option<Markup>, title: &str, description: Option<&str>) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col items-center justify-center py-12 text-center" {
|
||||
@@ -62,11 +43,3 @@ pub fn not_found_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_state_text(message: &str) -> Markup {
|
||||
html! {
|
||||
div class="text-neutral-500 text-center py-8" {
|
||||
(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,3 @@ pub fn error_alert(error: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_card(title: &str, message: &str) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="flex flex-col gap-4" {
|
||||
h3 class="text-base font-semibold text-gray-900" { (title) }
|
||||
p class="text-sm text-neutral-500" { (message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +151,6 @@ pub fn danger_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn secondary_button(label: &str) -> Markup {
|
||||
button_markup(label, "button", SECONDARY_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn form_field_group(
|
||||
label: &str,
|
||||
name: &str,
|
||||
@@ -222,7 +218,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
|
||||
div class="checkbox-custom" {
|
||||
(PreEscaped(CHECKMARK_SVG))
|
||||
}
|
||||
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
|
||||
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,39 +23,6 @@ pub fn paperclip_icon(color: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkmark_icon(color: &str) -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
|
||||
class={"inline-block h-4 w-4 " (color)}
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect width="256" height="256" fill="none"/>"#,
|
||||
r#"<polyline points="40 144 96 200 224 72" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn x_icon(color: &str) -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
|
||||
class={"inline-block h-4 w-4 " (color)}
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect width="256" height="256" fill="none"/>"#,
|
||||
r#"<line x1="200" y1="56" x2="56" y2="200" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
r#"<line x1="200" y1="200" x2="56" y2="56" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
@@ -82,63 +49,3 @@ pub fn spinner_icon() -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-4 w-4" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<circle cx="11" cy="11" r="8"/>"#,
|
||||
r#"<line x1="21" y1="21" x2="16.65" y2="16.65"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chevron_right_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-4 w-4" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(r#"<polyline points="9 18 15 12 9 6"/>"#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_link_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"#,
|
||||
r#"a2 2 0 0 1 2-2h6"/>"#,
|
||||
r#"<polyline points="15 3 21 3 21 9"/>"#,
|
||||
r#"<line x1="10" y1="14" x2="21" y2="3"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>"#,
|
||||
r#"<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9"#,
|
||||
r#"a2 2 0 0 1 2 2v1"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ pub struct Attachment {
|
||||
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 MissingAttachment {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub content_type: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -27,12 +32,15 @@ pub struct Message {
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub webhook_id: Option<String>,
|
||||
pub author_bot: Option<bool>,
|
||||
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 missing_attachments: Vec<MissingAttachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
@@ -48,6 +56,12 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
let missing_attachments = value["missing_attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(missing_attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -61,6 +75,8 @@ fn message_from_value(value: &Value) -> Message {
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
webhook_id: value_id(&value["webhook_id"]),
|
||||
author_bot: value["author_bot"].as_bool(),
|
||||
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"]
|
||||
@@ -71,6 +87,16 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_attachment_from_value(value: &Value) -> MissingAttachment {
|
||||
MissingAttachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
size: value["size"].as_u64(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +110,6 @@ fn attachment_from_value(value: &Value) -> Attachment {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +157,7 @@ mod tests {
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096,
|
||||
"ncmec_status": "submitted",
|
||||
"ncmec_report_id": "report-id",
|
||||
"ncmec_failure_reason": "previous failure"
|
||||
"size": 4096
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -157,6 +172,8 @@ mod tests {
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
@@ -171,14 +188,70 @@ mod tests {
|
||||
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()),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
@@ -187,6 +260,7 @@ mod tests {
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.webhook_id, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
@@ -199,9 +273,6 @@ mod tests {
|
||||
width: None,
|
||||
height: None,
|
||||
size: None,
|
||||
ncmec_status: "not_submitted".into(),
|
||||
ncmec_report_id: None,
|
||||
ncmec_failure_reason: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::icons::paperclip_icon;
|
||||
use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
@@ -17,33 +19,6 @@ fn is_image(att: &Attachment) -> bool {
|
||||
.is_some_and(|ct| ct.starts_with("image/"))
|
||||
}
|
||||
|
||||
fn ncmec_badge(att: &Attachment) -> Markup {
|
||||
match att.ncmec_status.as_str() {
|
||||
"submitted" => {
|
||||
let label = att
|
||||
.ncmec_report_id
|
||||
.as_deref()
|
||||
.map(|id| format!("NCMEC {id}"))
|
||||
.unwrap_or_else(|| "Reported to NCMEC".into());
|
||||
html! {
|
||||
span class="rounded bg-green-100 px-2 py-0.5 text-[11px] text-green-800"
|
||||
title=(label) { (label) }
|
||||
}
|
||||
}
|
||||
"failed" => {
|
||||
let title = att
|
||||
.ncmec_failure_reason
|
||||
.as_deref()
|
||||
.unwrap_or("NCMEC report failed");
|
||||
html! {
|
||||
span class="rounded bg-red-100 px-2 py-0.5 text-[11px] text-red-800"
|
||||
title=(title) { "NCMEC Failed" }
|
||||
}
|
||||
}
|
||||
_ => html! {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||
if images.peek().is_none() {
|
||||
@@ -70,7 +45,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
class="font-medium text-blue-600 hover:underline" { (att.filename) }
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
@if include_delete {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
@@ -80,32 +54,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
transition-colors hover:bg-red-50 hover:text-red-700"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id) { "Delete" }
|
||||
button type="button"
|
||||
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
|
||||
shadow-sm ring-1 ring-neutral-200 transition-colors \
|
||||
hover:bg-neutral-100 disabled:cursor-not-allowed \
|
||||
disabled:opacity-70"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id)
|
||||
data-attachment-id=(att.id)
|
||||
data-filename=(att.filename)
|
||||
data-content-type=(att.content_type.as_deref().unwrap_or(""))
|
||||
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
|
||||
data-author-id=(msg.author_id)
|
||||
data-ncmec-status=(att.ncmec_status)
|
||||
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
|
||||
disabled[att.ncmec_status == "submitted"]
|
||||
title=(if att.ncmec_status == "submitted" {
|
||||
"Already reported to NCMEC"
|
||||
} else {
|
||||
"Report this image to NCMEC"
|
||||
}) {
|
||||
@if att.ncmec_status == "submitted" {
|
||||
"Reported to NCMEC"
|
||||
} @else {
|
||||
"Report to NCMEC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +81,29 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline" { (att.filename) }
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
if msg.missing_attachments.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
div class="mt-1.5 space-y-1" {
|
||||
@for att in &msg.missing_attachments {
|
||||
div class="flex flex-wrap items-center gap-2 text-amber-800 text-xs"
|
||||
data-missing-attachment=(att.id) {
|
||||
(paperclip_icon("text-amber-500"))
|
||||
span class="break-words" {
|
||||
@if att.filename.is_empty() {
|
||||
"An attachment was not preserved in the report snapshot"
|
||||
} @else {
|
||||
(att.filename) " was not preserved in the report snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +149,10 @@ fn message_row(
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
@@ -208,20 +182,39 @@ fn message_row(
|
||||
style="display:grid;grid-template-columns:16px 40px 16px minmax(0,1fr);"
|
||||
data-message-id=(msg.id) data-message-row="" {
|
||||
div style="grid-row:1;grid-column:1;" {}
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span title=(webhook_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
}
|
||||
div style="grid-row:1;grid-column:3;" {}
|
||||
div class="min-w-0" style="grid-column:4;" {
|
||||
div class="flex items-baseline gap-2" {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span class="font-medium text-neutral-900 text-sm"
|
||||
title=(webhook_id) data-message-webhook=(webhook_id) { (msg.author_username) }
|
||||
span class="self-center" { (badge("Webhook", BadgeVariant::Info)) }
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if msg.author_bot == Some(true) {
|
||||
span class="self-center" data-message-author-bot=(msg.author_id) {
|
||||
(badge("Bot", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
span class="text-neutral-400 text-xs" {
|
||||
" \u{2014} " (msg.timestamp)
|
||||
" \u{2014} " (format_admin_timestamp(&msg.timestamp))
|
||||
}
|
||||
(channel_nsfw_state_badge(
|
||||
msg.channel_nsfw.unwrap_or(false),
|
||||
@@ -239,6 +232,7 @@ fn message_row(
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@@ -267,7 +261,9 @@ pub fn message_list(
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@for (i, msg) in messages.iter().enumerate() {
|
||||
@let is_grouped = i > 0 && messages[i - 1].author_id == msg.author_id;
|
||||
@let is_grouped = i > 0
|
||||
&& messages[i - 1].author_id == msg.author_id
|
||||
&& messages[i - 1].author_username == msg.author_username;
|
||||
@let is_highlighted = highlight_message_id == Some(msg.id.as_str());
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
@@ -322,37 +318,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
toast('error', 'Failed to delete message.');
|
||||
});
|
||||
}
|
||||
function reportNcmec(b) {
|
||||
var name = prompt('Type your full name to confirm you personally viewed this image and want to submit it to NCMEC.');
|
||||
if (!name || !name.trim()) return;
|
||||
var fields = new URLSearchParams();
|
||||
fields.append('channel_id', b.dataset.channelId || '');
|
||||
fields.append('message_id', b.dataset.messageId || '');
|
||||
fields.append('attachment_id', b.dataset.attachmentId || '');
|
||||
fields.append('filename', b.dataset.filename || '');
|
||||
fields.append('reporter_full_name', name.trim());
|
||||
fields.append('confirmed_viewed', 'true');
|
||||
b.disabled = true;
|
||||
b.textContent = 'Reporting...';
|
||||
toast('info', 'Submitting NCMEC report...');
|
||||
post('report-to-ncmec', fields).then(function(r) {
|
||||
return r.json().catch(function() {
|
||||
return null;
|
||||
}).then(function(data) {
|
||||
if (!r.ok || !data || data.success !== true) throw new Error(data && (data.error || data.message) || 'Failed to report attachment to NCMEC');
|
||||
return data;
|
||||
});
|
||||
}).then(function(data) {
|
||||
b.textContent = 'Reported to NCMEC';
|
||||
b.dataset.ncmecStatus = 'submitted';
|
||||
if (data.ncmec_report_id) b.dataset.ncmecReportId = data.ncmec_report_id;
|
||||
toast('success', 'NCMEC report submitted.');
|
||||
}).catch(function(err) {
|
||||
b.disabled = false;
|
||||
b.textContent = 'Report to NCMEC';
|
||||
toast('error', err && err.message ? err.message : 'Failed to report attachment to NCMEC.');
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
@@ -360,12 +325,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
if (d instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
deleteMessage(d);
|
||||
return;
|
||||
}
|
||||
var n = t.closest('.ncmec-report-btn');
|
||||
if (n instanceof HTMLButtonElement && !n.disabled) {
|
||||
e.preventDefault();
|
||||
reportNcmec(n);
|
||||
}
|
||||
});
|
||||
})();"#
|
||||
|
||||
@@ -17,12 +17,12 @@ pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod stack;
|
||||
pub mod table;
|
||||
pub mod tooltip;
|
||||
pub mod typography;
|
||||
pub mod user_display;
|
||||
pub mod user_profile_badges;
|
||||
pub mod voice;
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
pub fn page_container(spacing: &str, content: Markup) -> Markup {
|
||||
let class = match spacing {
|
||||
"sm" => "space-y-4",
|
||||
"lg" => "space-y-8",
|
||||
_ => "space-y-6",
|
||||
};
|
||||
html! {
|
||||
div class=(class) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_container_md(content: Markup) -> Markup {
|
||||
page_container("md", content)
|
||||
}
|
||||
|
||||
pub fn page_header(title: &str, description: Option<&str>) -> Markup {
|
||||
page_header_full(title, description, None, None, None, maud::html! {})
|
||||
}
|
||||
@@ -122,25 +107,3 @@ pub fn detail_row(label: &str, value: Markup) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tabs(tabs: &[(&str, &str, bool)], base_url: &str) -> Markup {
|
||||
html! {
|
||||
div class="border-b border-neutral-200 mb-6" {
|
||||
nav class="-mb-px flex gap-6" aria-label="Tabs" {
|
||||
@for (id, label, active) in tabs {
|
||||
@let classes = if *active {
|
||||
"border-brand-primary text-brand-primary whitespace-nowrap border-b-2 \
|
||||
pb-3 pt-1 text-sm font-medium"
|
||||
} else {
|
||||
"border-transparent text-neutral-500 hover:border-neutral-300 \
|
||||
hover:text-neutral-700 whitespace-nowrap border-b-2 pb-3 pt-1 \
|
||||
text-sm font-medium"
|
||||
};
|
||||
a href={(base_url) "?tab=" (id)} class=(classes) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
("harassment", "Harassment or bullying"),
|
||||
("hate_speech", "Hate speech"),
|
||||
("spam", "Spam"),
|
||||
("illegal_activity", "Illegal activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child safety concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or graphic content"),
|
||||
("nsfw_violation", "NSFW policy violation"),
|
||||
("doxxing", "Sharing personal information"),
|
||||
("self_harm", "Self-harm or suicide"),
|
||||
("malicious_links", "Malicious links"),
|
||||
("spam_account", "Spam account"),
|
||||
("underage_user", "Underage user"),
|
||||
("inappropriate_profile", "Inappropriate profile"),
|
||||
("raid_coordination", "Raid coordination"),
|
||||
("malware_distribution", "Malware distribution"),
|
||||
("extremist_community", "Extremist community"),
|
||||
];
|
||||
|
||||
pub fn report_category_label(category: &str) -> &str {
|
||||
REPORT_CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(key, label)| (*key == category).then_some(*label))
|
||||
.unwrap_or(category)
|
||||
}
|
||||
|
||||
pub fn reason_repeats_category(category: &str, reason_label: &str) -> bool {
|
||||
report_category_label(category).trim().to_lowercase() == reason_label.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn report_category_options(selected: &str) -> Vec<(&str, &str)> {
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(REPORT_CATEGORIES.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
pub fn report_category(category: &str) -> Markup {
|
||||
html! {
|
||||
span data-report-category=(category) title=(category) {
|
||||
(report_category_label(category))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::media::user_avatar_url;
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
|
||||
pub struct ReportedWebhook<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: String,
|
||||
pub configured_name: Option<&'a str>,
|
||||
pub configured_avatar_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub reports_href: String,
|
||||
pub kind: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub channel_id: Option<&'a str>,
|
||||
pub guild_id: Option<&'a str>,
|
||||
pub application: Option<WebhookApplication<'a>>,
|
||||
pub record_deleted: bool,
|
||||
pub creator: Option<WebhookCreator<'a>>,
|
||||
}
|
||||
|
||||
pub struct WebhookApplication<'a> {
|
||||
pub id: &'a str,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
pub struct WebhookCreator<'a> {
|
||||
pub id: &'a str,
|
||||
pub label: String,
|
||||
pub avatar_url: String,
|
||||
pub href: String,
|
||||
pub account_deleted: bool,
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
pub fn webhook_type_label(webhook_type: i32) -> String {
|
||||
match webhook_type {
|
||||
1 => "Incoming".to_owned(),
|
||||
2 => "Channel Follower".to_owned(),
|
||||
other => format!("Type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn reported_webhook<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<ReportedWebhook<'a>> {
|
||||
let id = report.reported_webhook_id.as_deref()?;
|
||||
let name = non_blank(report.reported_webhook_name.as_deref())
|
||||
.map_or_else(|| format!("Webhook {id}"), str::to_owned);
|
||||
let configured_name = non_blank(report.reported_webhook_default_name.as_deref())
|
||||
.filter(|configured| *configured != name);
|
||||
let channel_id = non_blank(report.reported_webhook_channel_id.as_deref());
|
||||
let guild_id = non_blank(report.reported_webhook_guild_id.as_deref());
|
||||
let record_present = report.reported_webhook_type.is_some()
|
||||
|| channel_id.is_some()
|
||||
|| guild_id.is_some()
|
||||
|| non_blank(report.reported_webhook_default_name.as_deref()).is_some();
|
||||
let configured_avatar_hash = non_blank(report.reported_webhook_default_avatar_hash.as_deref());
|
||||
let configured_avatar_url = (record_present
|
||||
&& configured_avatar_hash != non_blank(report.reported_webhook_avatar_hash.as_deref()))
|
||||
.then(|| user_avatar_url(config, id, configured_avatar_hash, 80, true));
|
||||
let created_at = non_blank(report.reported_webhook_created_at.as_deref());
|
||||
Some(ReportedWebhook {
|
||||
id,
|
||||
name,
|
||||
configured_name,
|
||||
configured_avatar_url,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
reports_href: format!(
|
||||
"{}/reports?reported_webhook_id={}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
kind: report.reported_webhook_type.map(webhook_type_label),
|
||||
created_at: created_at.map(format_admin_timestamp),
|
||||
channel_id,
|
||||
guild_id,
|
||||
application: non_blank(report.reported_webhook_application_id.as_deref()).map(|id| {
|
||||
WebhookApplication {
|
||||
id,
|
||||
href: format!(
|
||||
"{}/applications/{}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
}
|
||||
}),
|
||||
record_deleted: created_at.is_some() && !record_present,
|
||||
creator: webhook_creator(config, report),
|
||||
})
|
||||
}
|
||||
|
||||
fn webhook_creator<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<WebhookCreator<'a>> {
|
||||
let id = report.reported_webhook_creator_id.as_deref()?;
|
||||
let username = non_blank(report.reported_webhook_creator_username.as_deref());
|
||||
let bot = report.reported_webhook_application_id.as_deref() == Some(id);
|
||||
let tag = match username {
|
||||
Some(username) => Some(user_tag(
|
||||
username,
|
||||
report
|
||||
.reported_webhook_creator_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000"),
|
||||
bot,
|
||||
)),
|
||||
None => non_blank(report.reported_webhook_creator_tag.as_deref()).map(str::to_owned),
|
||||
};
|
||||
let label = match (
|
||||
non_blank(report.reported_webhook_creator_global_name.as_deref()),
|
||||
tag,
|
||||
) {
|
||||
(Some(display), Some(tag)) => format!("{display} ({tag})"),
|
||||
(_, Some(tag)) => tag,
|
||||
(Some(display), None) => display.to_owned(),
|
||||
(None, None) => format!("User {id}"),
|
||||
};
|
||||
Some(WebhookCreator {
|
||||
id,
|
||||
label,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_creator_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
href: format!("{}/users/{}", config.base_path, urlencoding::encode(id)),
|
||||
account_deleted: username.is_none()
|
||||
&& non_blank(report.reported_webhook_creator_tag.as_deref()).is_none()
|
||||
&& non_blank(report.reported_webhook_creator_global_name.as_deref()).is_none(),
|
||||
bot,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn webhook_identity(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
span class="inline-flex min-w-0 items-center gap-2" data-report-webhook=(webhook.id) {
|
||||
img src=(webhook.avatar_url) alt=(format!("{}'s avatar", webhook.name))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" {
|
||||
span class="font-medium text-neutral-900 text-sm break-words" { (webhook.name) }
|
||||
(badge("Webhook", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_link(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="inline-flex min-w-0 items-center gap-2 text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
img src=(creator.avatar_url) alt=(format!("{}'s avatar", creator.label))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="break-words" { (creator.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_badges(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
@if creator.bot {
|
||||
span class="inline-flex" data-report-webhook-creator-bot { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
@if creator.account_deleted {
|
||||
span class="inline-flex" data-report-webhook-creator-deleted { (badge("Account Deleted", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ pub enum ResourceType {
|
||||
|
||||
const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
|
||||
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";
|
||||
|
||||
impl ResourceType {
|
||||
fn path_segment(self) -> &'static str {
|
||||
@@ -20,20 +18,6 @@ impl ResourceType {
|
||||
ResourceType::Guild => "guilds",
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_drawer_id(self) -> &'static str {
|
||||
match self {
|
||||
ResourceType::User => "user-peek",
|
||||
ResourceType::Guild => "guild-peek",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ResourceType::User => "User",
|
||||
ResourceType::Guild => "Guild",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_link(
|
||||
@@ -52,40 +36,3 @@ pub fn resource_link(
|
||||
a href=(href) class=(RESOURCE_LINK_CLASS) { (display) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_link_peek(
|
||||
base_path: &str,
|
||||
resource_type: ResourceType,
|
||||
resource_id: &str,
|
||||
display: Markup,
|
||||
peek_title: Option<&str>,
|
||||
) -> Markup {
|
||||
let href = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
resource_type.path_segment(),
|
||||
resource_id,
|
||||
);
|
||||
let fragment_href = format!("{}/fragment", href);
|
||||
let title = peek_title
|
||||
.map(|t| t.to_owned())
|
||||
.unwrap_or_else(|| format!("{} {}", resource_type.label(), resource_id));
|
||||
html! {
|
||||
a href=(href)
|
||||
class=(RESOURCE_LINK_CLASS)
|
||||
data-drawer-open=(resource_type.peek_drawer_id())
|
||||
data-drawer-href=(fragment_href)
|
||||
hx-get=(fragment_href)
|
||||
hx-target={"#" (resource_type.peek_drawer_id()) "-body"}
|
||||
hx-swap="innerHTML"
|
||||
data-drawer-title=(title) {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nav_link(href: &str, content: Markup) -> Markup {
|
||||
html! {
|
||||
a href=(href) class=(NAV_LINK_CLASS) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum StackGap {
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum StackAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
fn gap_class(gap: StackGap) -> &'static str {
|
||||
match gap {
|
||||
StackGap::Sm => "gap-2",
|
||||
StackGap::Md => "gap-4",
|
||||
StackGap::Lg => "gap-6",
|
||||
}
|
||||
}
|
||||
|
||||
fn align_class(align: StackAlign) -> &'static str {
|
||||
match align {
|
||||
StackAlign::Start => "items-start",
|
||||
StackAlign::Center => "items-center",
|
||||
StackAlign::End => "items-end",
|
||||
StackAlign::Stretch => "items-stretch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
|
||||
let classes = format!("flex flex-col {} {}", gap_class(gap), align_class(align),);
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
|
||||
let classes = format!("flex flex-row {} {}", gap_class(gap), align_class(align),);
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stack(content: Markup) -> Markup {
|
||||
vstack(StackGap::Md, StackAlign::Stretch, content)
|
||||
}
|
||||
@@ -45,10 +45,6 @@ pub fn table_cell(muted: bool, content: Markup) -> Markup {
|
||||
html! { td class=(table_cell_class(muted)) { (content) } }
|
||||
}
|
||||
|
||||
pub fn table_cell_span(muted: bool, col_span: u32, content: Markup) -> Markup {
|
||||
html! { td class=(table_cell_class(muted)) colspan=(col_span) { (content) } }
|
||||
}
|
||||
|
||||
fn table_cell_class(muted: bool) -> &'static str {
|
||||
if muted {
|
||||
"px-3 py-3 text-sm sm:px-6 sm:py-4 text-neutral-600"
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum HeadingLevel {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
H4,
|
||||
H5,
|
||||
H6,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum HeadingSize {
|
||||
Xs,
|
||||
Sm,
|
||||
Base,
|
||||
Lg,
|
||||
Xl,
|
||||
Xl2,
|
||||
Xl3,
|
||||
Xl4,
|
||||
}
|
||||
|
||||
fn default_heading_classes(level: HeadingLevel) -> &'static str {
|
||||
match level {
|
||||
HeadingLevel::H1 => "text-3xl font-bold",
|
||||
HeadingLevel::H2 => "text-2xl font-semibold",
|
||||
HeadingLevel::H3 => "text-xl font-semibold",
|
||||
HeadingLevel::H4 => "text-lg font-semibold",
|
||||
HeadingLevel::H5 => "text-base font-semibold",
|
||||
HeadingLevel::H6 => "text-sm font-semibold",
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_size_class(size: HeadingSize) -> &'static str {
|
||||
match size {
|
||||
HeadingSize::Xs => "text-xs",
|
||||
HeadingSize::Sm => "text-sm",
|
||||
HeadingSize::Base => "text-base",
|
||||
HeadingSize::Lg => "text-lg",
|
||||
HeadingSize::Xl => "text-xl",
|
||||
HeadingSize::Xl2 => "text-2xl",
|
||||
HeadingSize::Xl3 => "text-3xl",
|
||||
HeadingSize::Xl4 => "text-4xl",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heading(level: HeadingLevel, size: Option<HeadingSize>, content: Markup) -> Markup {
|
||||
let size_classes = match size {
|
||||
Some(s) => custom_size_class(s),
|
||||
None => default_heading_classes(level),
|
||||
};
|
||||
let classes = format!("text-gray-900 tracking-tight {size_classes}");
|
||||
match level {
|
||||
HeadingLevel::H1 => html! { h1 class=(classes) { (content) } },
|
||||
HeadingLevel::H2 => html! { h2 class=(classes) { (content) } },
|
||||
HeadingLevel::H3 => html! { h3 class=(classes) { (content) } },
|
||||
HeadingLevel::H4 => html! { h4 class=(classes) { (content) } },
|
||||
HeadingLevel::H5 => html! { h5 class=(classes) { (content) } },
|
||||
HeadingLevel::H6 => html! { h6 class=(classes) { (content) } },
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TextSize {
|
||||
Xs,
|
||||
Sm,
|
||||
Base,
|
||||
Lg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TextColor {
|
||||
Default,
|
||||
Muted,
|
||||
Primary,
|
||||
Danger,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn text(size: TextSize, color: TextColor, content: Markup) -> Markup {
|
||||
let size_class = match size {
|
||||
TextSize::Xs => "text-xs",
|
||||
TextSize::Sm => "text-sm",
|
||||
TextSize::Base => "text-base",
|
||||
TextSize::Lg => "text-lg",
|
||||
};
|
||||
let color_class = match color {
|
||||
TextColor::Default => "text-gray-900",
|
||||
TextColor::Muted => "text-neutral-500",
|
||||
TextColor::Primary => "text-brand-primary",
|
||||
TextColor::Danger => "text-red-600",
|
||||
TextColor::Success => "text-green-600",
|
||||
};
|
||||
html! {
|
||||
p class={(size_class) " font-normal " (color_class)} { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caption(content: Markup) -> Markup {
|
||||
html! {
|
||||
p class="text-xs text-gray-500" { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum CaptionVariant {
|
||||
Default,
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn caption_variant(variant: CaptionVariant, content: Markup) -> Markup {
|
||||
let color = match variant {
|
||||
CaptionVariant::Default => "text-gray-500",
|
||||
CaptionVariant::Error => "text-red-600",
|
||||
CaptionVariant::Success => "text-green-600",
|
||||
};
|
||||
html! {
|
||||
p class={"text-xs " (color)} { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label_text(for_id: Option<&str>, required: bool, content: Markup) -> Markup {
|
||||
let classes = "block text-xs font-semibold uppercase tracking-wide text-neutral-500";
|
||||
html! {
|
||||
label for=[for_id] class=(classes) {
|
||||
(content)
|
||||
@if required {
|
||||
span class="ml-1 text-red-600" { "*" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn section_heading(title: &str, actions: Option<Markup>) -> Markup {
|
||||
match actions {
|
||||
Some(action_content) => html! {
|
||||
div class="mb-4 flex items-center justify-between" {
|
||||
h2 class="font-semibold text-gray-900 text-xl" { (title) }
|
||||
div class="flex items-center gap-2" { (action_content) }
|
||||
}
|
||||
},
|
||||
None => html! {
|
||||
h2 class="mb-4 font-semibold text-gray-900 text-xl" { (title) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_text(content: &str) -> Markup {
|
||||
html! { span { (content) } }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::user_flag_bits;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub mod premium_types {
|
||||
@@ -22,18 +23,21 @@ fn premium_tooltip(
|
||||
if is_self_hosted {
|
||||
let name = self_hosted_premium_name?;
|
||||
return Some(match premium_since {
|
||||
Some(since) => format!("{name} subscriber since {since}"),
|
||||
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
|
||||
None => name.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(if premium_type == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
|
||||
Some(since) => format!(
|
||||
"Fluxer Plutonium subscriber since {}",
|
||||
format_admin_timestamp(since)
|
||||
),
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -126,9 +126,3 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flash_container() -> Markup {
|
||||
html! {
|
||||
div id="flash-container" class="mb-4 sm:mb-6 empty:hidden" {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +489,11 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
element = element.firstElementChild;
|
||||
depth += 1;
|
||||
}
|
||||
var level = status >= 400 ? 'error' : 'success';
|
||||
var level = 'success';
|
||||
if (className.indexOf('red-') >= 0) level = 'error';
|
||||
if (className.indexOf('green-') >= 0) level = 'success';
|
||||
if (className.indexOf('blue-') >= 0) level = 'info';
|
||||
if (status >= 400) level = 'error';
|
||||
return {
|
||||
level: level,
|
||||
message: text || (level === 'error' ? 'Action failed.' : 'Done.')
|
||||
@@ -514,6 +515,20 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAfterSuccess(detail, level) {
|
||||
if (level !== 'success') return;
|
||||
var elt = (detail && detail.requestConfig && detail.requestConfig.elt) || mutationElement(detail);
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
var selector = form ? form.getAttribute('data-admin-refresh-on-success') : '';
|
||||
if (!selector || !document.querySelector(selector)) return;
|
||||
if (!window.htmx || typeof window.htmx.ajax !== 'function') return;
|
||||
window.htmx.ajax('GET', window.location.pathname + window.location.search, {
|
||||
target: selector,
|
||||
swap: 'outerHTML',
|
||||
select: selector
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('showFlash', function (evt) {
|
||||
var d = evt.detail || {};
|
||||
showToast(d.level || 'info', d.message || '');
|
||||
@@ -533,11 +548,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
var xhr = event.detail.xhr;
|
||||
var parsed = isLoginResponse(xhr)
|
||||
? {level: 'error', message: 'Session expired. Sign in again.'}
|
||||
: parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
: parseAdminToastHeader(xhr) || parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
event.detail.shouldSwap = false;
|
||||
event.detail.isError = false;
|
||||
markToastHandled(event.detail);
|
||||
showToast(parsed.level, parsed.message);
|
||||
refreshAfterSuccess(event.detail, parsed.level);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function (event) {
|
||||
@@ -548,6 +564,7 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
if (serverToast) {
|
||||
showToast(serverToast.level, serverToast.message);
|
||||
markToastHandled(event.detail);
|
||||
refreshAfterSuccess(event.detail, serverToast.level);
|
||||
return;
|
||||
}
|
||||
var failed = !event.detail.successful || (xhr && xhr.status >= 400) || isLoginResponse(xhr);
|
||||
@@ -568,25 +585,6 @@ window.__adminCopyToClipboard = function (text, btn, successLabel) {
|
||||
};
|
||||
"#;
|
||||
|
||||
pub const ARCHIVE_POLL_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var rows = document.querySelectorAll('tr[data-archive-id]');
|
||||
var hasPending = false;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var status = rows[i].querySelector('.archive-status');
|
||||
if (status && status.textContent.indexOf('Completed') === -1 && status.textContent.indexOf('Failed') === -1) {
|
||||
hasPending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasPending) {
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
}
|
||||
})();
|
||||
"#;
|
||||
|
||||
pub const SH_LINK_REWRITE_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
if (window.location.search.indexOf('sh=1') === -1) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{acl, config::AdminConfig};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
|
||||
pub use super::layout_sidebar_nav::{NAV_SECTIONS, NavItem, NavSection};
|
||||
use super::layout_sidebar_nav::NAV_SECTIONS;
|
||||
|
||||
pub fn render_sidebar(
|
||||
config: &AdminConfig,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
|
||||
config::AdminConfig,
|
||||
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
|
||||
"Key ID: " (key_id)
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Created: " (created_at)
|
||||
"Created: " (format_admin_timestamp(created_at))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Created by: " (key.created_by_user_id)
|
||||
}
|
||||
@if let Some(ref expires_at) = key.expires_at {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Expires: " (expires_at)
|
||||
"Expires: " (format_admin_timestamp(expires_at))
|
||||
}
|
||||
}
|
||||
@if !key.acls.is_empty() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::Archive,
|
||||
config::AdminConfig,
|
||||
@@ -69,7 +70,7 @@ fn archive_table(archives: &[Archive], base: &str) -> Markup {
|
||||
span class="text-sm" { (archive.requested_by) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
span class="text-sm" { (archive.requested_at) }
|
||||
span class="text-sm" { (format_admin_timestamp(&archive.requested_at)) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
div class="flex flex-col gap-1" {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::noun_for;
|
||||
use crate::{
|
||||
api::types::AuditLogsListResponse,
|
||||
config::AdminConfig,
|
||||
@@ -120,7 +121,12 @@ pub fn audit_logs_page(
|
||||
.current_page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
let showing = format!("Showing {} of {} entries", entries.len(), total);
|
||||
let showing = format!(
|
||||
"Showing {} of {} {}",
|
||||
entries.len(),
|
||||
total,
|
||||
noun_for(total, "entry", "entries")
|
||||
);
|
||||
html! {
|
||||
(page_header_with_actions("Audit Logs", None, html! {
|
||||
span class="text-sm text-neutral-500" { (showing) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{
|
||||
AuditLogChannelSummary, AuditLogEntry, AuditLogGuildSummary, AuditLogUserSummary,
|
||||
@@ -273,7 +274,7 @@ pub fn log_row(base: &str, entry: &AuditLogEntry, index: usize) -> Markup {
|
||||
(table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="text-sm whitespace-nowrap" {
|
||||
(entry.created_at)
|
||||
(format_admin_timestamp(&entry.created_at))
|
||||
}
|
||||
}))
|
||||
(table_cell(false, badge(&format_action(&entry.action), action_badge_variant(&entry.action))))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -35,7 +36,7 @@ pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|| count_noun(u64::from(hours), "hour", "hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
|
||||
name: "SPAMMER",
|
||||
value: 1 << 6,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PROFILE_HIDDEN",
|
||||
value: 1 << 7,
|
||||
},
|
||||
UserFlag {
|
||||
name: "HIGH_GLOBAL_RATE_LIMIT",
|
||||
value: 1 << 33,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::gateway_leaderboard;
|
||||
use super::gateway_nodes;
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::{GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse},
|
||||
config::AdminConfig,
|
||||
@@ -43,7 +44,7 @@ pub fn gateway_page(config: &AdminConfig, auth: &AuthContext, p: &GatewayPagePar
|
||||
))
|
||||
@if let Some(count) = p.reload_result {
|
||||
div class="rounded-lg border p-4 bg-green-50 border-green-200 text-green-700" {
|
||||
"Successfully reloaded " (count) " guilds!"
|
||||
"Successfully reloaded " (count_noun(count, "guild", "guilds")) "!"
|
||||
}
|
||||
}
|
||||
div class="space-y-6" {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{GuildInfo, GuildMember, ListGuildMembersResponse},
|
||||
config::AdminConfig,
|
||||
@@ -149,11 +151,11 @@ fn member_card(
|
||||
"ID: " (member.user.id)
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Joined: " (member.joined_at)
|
||||
"Joined: " (format_admin_timestamp(&member.joined_at))
|
||||
}
|
||||
@if !member.roles.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
(member.roles.len()) " roles"
|
||||
(count_noun(member.roles.len() as u64, "role", "roles"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::user_tag::user_tag,
|
||||
templates::{
|
||||
components::page_container::card_with_header,
|
||||
pages::user_detail_tabs::reports::{
|
||||
format_status, report_tab_table, reported_at_cell, reporter_label,
|
||||
type_and_category_cell,
|
||||
},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -39,9 +44,8 @@ pub fn reports_tab(
|
||||
}
|
||||
}))
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"],
|
||||
(report_tab_table(
|
||||
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
|
||||
html! {
|
||||
@for report in reports {
|
||||
(report_row(base, report))
|
||||
@@ -74,81 +78,28 @@ pub fn reports_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
let reporter_display = format_reporter(report);
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
|
||||
@if let Some(ref rid) = report.reporter_id {
|
||||
a href={(base) "/users/" (rid)}
|
||||
class="hover:underline" {
|
||||
(reporter_display)
|
||||
(reporter_label(report))
|
||||
}
|
||||
} @else {
|
||||
span { (reporter_display) }
|
||||
span { (reporter_label(report)) }
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
|
||||
(format_status(report.status))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
|
||||
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
|
||||
(format_status(report.status))
|
||||
}
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border border-neutral-300 \
|
||||
bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 \
|
||||
@@ -159,3 +110,49 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,21 @@ pub fn threads_tab(
|
||||
guild: &GuildInfo,
|
||||
threads: &[GuildThreadItem],
|
||||
can_delete: bool,
|
||||
can_reindex: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@if can_reindex {
|
||||
(card_with_header("Thread search index", html! {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
|
||||
class="w-full" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="index_type" value="threads";
|
||||
(form_actions(html! {
|
||||
(submit_button("Refresh threads"))
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
(card_with_header("Thread search index", html! {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
|
||||
class="w-full" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="index_type" value="threads";
|
||||
(form_actions(html! {
|
||||
(submit_button("Refresh threads"))
|
||||
}))
|
||||
}
|
||||
}))
|
||||
(card_with_header(
|
||||
&format!("Threads ({})", threads.len()),
|
||||
html! {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::GuildInfo,
|
||||
config::AdminConfig,
|
||||
@@ -20,7 +21,6 @@ use maud::{Markup, html};
|
||||
|
||||
pub struct GuildsListParams {
|
||||
pub q: String,
|
||||
pub ids: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub limit: u32,
|
||||
pub page: u32,
|
||||
@@ -37,7 +37,6 @@ impl GuildsListParams {
|
||||
Self {
|
||||
q: q.unwrap_or_default().trim().to_owned(),
|
||||
requested_ids: parse_comma_separated(&ids),
|
||||
ids: ids.trim().to_owned(),
|
||||
limit: match limit.unwrap_or(50) {
|
||||
25 => 25,
|
||||
100 => 100,
|
||||
@@ -97,7 +96,7 @@ pub fn guilds_list_page(
|
||||
} @else if let Some(count) = result_state.total {
|
||||
(page_header_with_actions("Guilds", None, html! {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
"Found " (count) " results (showing "
|
||||
"Found " (count_noun(count, "result", "results")) " (showing "
|
||||
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
|
||||
")"
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ use crate::{
|
||||
api::types::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
|
||||
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, TagStyle,
|
||||
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -168,6 +167,7 @@ pub fn instance_config_page(
|
||||
csrf_token,
|
||||
&instance_config.integrations,
|
||||
instance_config.account_identity.mode,
|
||||
&instance_config.app_public.branding.product_name,
|
||||
))
|
||||
},
|
||||
))
|
||||
@@ -193,10 +193,6 @@ pub fn instance_config_page(
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
|
||||
@if !instance_config.self_hosted {
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
}
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -573,6 +569,7 @@ fn integrations_config_section(
|
||||
csrf_token: &str,
|
||||
integrations: &InstanceIntegrationsResponse,
|
||||
account_identity: AccountIdentityMode,
|
||||
product_name: &str,
|
||||
) -> Markup {
|
||||
let smtp_port = integrations
|
||||
.email
|
||||
@@ -634,7 +631,7 @@ fn integrations_config_section(
|
||||
"integration_email_from_name",
|
||||
"From name",
|
||||
integrations.email.from_name.as_deref().unwrap_or(""),
|
||||
"Fluxer",
|
||||
product_name,
|
||||
))
|
||||
(text_input(
|
||||
"integration_smtp_host",
|
||||
@@ -657,7 +654,7 @@ fn integrations_config_section(
|
||||
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
|
||||
}
|
||||
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
|
||||
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
|
||||
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="submit"
|
||||
formaction={(base) "/instance-config?action=test_smtp"}
|
||||
@@ -960,9 +957,18 @@ fn app_public_config_section(
|
||||
app_public.legal.privacy_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/privacy",
|
||||
))
|
||||
(text_input(
|
||||
"app_guidelines_url",
|
||||
"Community Guidelines URL",
|
||||
app_public.legal.guidelines_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/guidelines",
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Registration asks for agreement only to the documents configured here. Leave both blank to hide legal consent on self-hosted registration."
|
||||
"Registration asks for agreement only to the terms and privacy documents configured here. Leave both blank to hide legal consent on self-hosted registration."
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The community guidelines URL is linked from the app, the report flow and enforcement emails. Use an absolute http or https URL. Leave it blank to hide those links."
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Legal Documents"))
|
||||
@@ -1281,147 +1287,6 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn plutonium_page_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
plutonium_page: &PlutoniumPageConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if plutonium_page.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = plutonium_page.included_user_ids.join("\n");
|
||||
let excluded_user_ids = plutonium_page.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Plutonium page",
|
||||
"Replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable \
|
||||
in chat, and uses a minimal gift purchase modal.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_plutonium_page"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (plutonium_page.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"plutonium_page_enabled",
|
||||
"true",
|
||||
"Serve the Plutonium page to the selected users",
|
||||
plutonium_page.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every \
|
||||
client keeps the Plutonium settings tab, so the rollout and targeting \
|
||||
fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"plutonium_page_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&plutonium_page.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the Plutonium page, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"plutonium_page_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&plutonium_page.rollout_salt,
|
||||
PLUTONIUM_PAGE_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"plutonium_page_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
plutonium_page.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"plutonium_page_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
plutonium_page.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"plutonium_page_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&plutonium_page.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
plutonium_page.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"plutonium_page_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
plutonium_page.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Plutonium Page Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
|
||||
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
|
||||
}
|
||||
@@ -1489,48 +1354,6 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
|
||||
channel_threads.enabled
|
||||
&& channel_threads.guild_basis_points >= 10_000
|
||||
&& channel_threads.user_basis_points >= 10_000
|
||||
&& channel_threads.disabled_guild_ids.is_empty()
|
||||
&& channel_threads.excluded_user_ids.is_empty()
|
||||
}
|
||||
|
||||
fn channel_threads_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
channel_threads: &ChannelThreadsConfigResponse,
|
||||
) -> Markup {
|
||||
let everyone = channel_threads_available_to_everyone(channel_threads);
|
||||
section_card_with_description(
|
||||
"Threads and forums",
|
||||
"Threads, forum channels and media channels in every community.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(checkbox(
|
||||
"channel_threads_everyone",
|
||||
"true",
|
||||
"Available to everyone",
|
||||
everyone,
|
||||
true,
|
||||
))
|
||||
@if channel_threads.enabled && !everyone {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Currently on for part of this instance. Saving applies the setting above to everyone."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1541,7 +1364,7 @@ fn experiment_delivery_section(
|
||||
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||
and covers every experiment, not just the ones above. Raising the interval sheds \
|
||||
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||
spreads a fleet that has synchronized on one tick back out across the interval.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -2154,6 +1977,7 @@ mod tests {
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Username,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(!username.contains("Email delivery"));
|
||||
@@ -2166,6 +1990,7 @@ mod tests {
|
||||
"csrf",
|
||||
&integrations,
|
||||
AccountIdentityMode::Email,
|
||||
"Fluxer",
|
||||
)
|
||||
.into_string();
|
||||
assert!(email.contains("Email delivery"));
|
||||
@@ -2274,66 +2099,6 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_threads_section_is_a_single_everyone_toggle() {
|
||||
let everyone = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
guild_basis_points: 10_000,
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
|
||||
assert!(on.contains("action=update_channel_threads"));
|
||||
assert!(on.contains("name=\"channel_threads_everyone\""));
|
||||
assert!(on.contains("checked"));
|
||||
assert!(!on.contains("basis_points"));
|
||||
assert!(!on.contains("part of this instance"));
|
||||
|
||||
let partial = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
|
||||
assert!(!partial.contains("checked"));
|
||||
assert!(partial.contains("part of this instance"));
|
||||
|
||||
let off =
|
||||
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
|
||||
.into_string();
|
||||
assert!(!off.contains("checked"));
|
||||
assert!(!off.contains("part of this instance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
|
||||
let plutonium_page = PlutoniumPageConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 250,
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..PlutoniumPageConfigResponse::default()
|
||||
};
|
||||
let markup = plutonium_page_section("/admin", "csrf", &plutonium_page).into_string();
|
||||
assert!(markup.contains("Plutonium page"));
|
||||
assert!(markup.contains("action=update_plutonium_page"));
|
||||
assert!(markup.contains("name=\"plutonium_page_enabled\""));
|
||||
assert!(markup.contains("name=\"plutonium_page_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"plutonium_page_include_premium_users\""));
|
||||
assert!(markup.contains("name=\"plutonium_page_included_guild_ids\""));
|
||||
assert!(markup.contains("Config version 3"));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("anonymous_rollout_basis_points"));
|
||||
assert!(!markup.contains("standalone_forwarding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::jobs_list_helpers::status_badge;
|
||||
use super::jobs_list_helpers::{job_time, 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("")
|
||||
@@ -113,10 +113,10 @@ fn overview_section(job: &serde_json::Value) -> Markup {
|
||||
html! {
|
||||
(data_field_mono("Task", task_type))
|
||||
(data_field_mono("Lane", if lane.is_empty() { "\u{2014}" } else { lane }))
|
||||
(data_field_text("Created", if created_at.is_empty() { "\u{2014}" } else { created_at }))
|
||||
(data_field_text("Started", if started_at.is_empty() { "\u{2014}" } else { started_at }))
|
||||
(data_field_text("Completed", if completed_at.is_empty() { "\u{2014}" } else { completed_at }))
|
||||
(data_field_text("Run-at", run_at))
|
||||
(data_field_text("Created", &job_time(created_at)))
|
||||
(data_field_text("Started", &job_time(started_at)))
|
||||
(data_field_text("Completed", &job_time(completed_at)))
|
||||
(data_field_text("Run-at", &job_time(run_at)))
|
||||
(data_field_text("Attempts", &format!("{attempts}/{max_attempts}")))
|
||||
(data_field_mono("Scheduled by", requester_display))
|
||||
(data_field_text("Audit reason", if audit_reason.is_empty() { "\u{2014}" } else { audit_reason }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -25,7 +26,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
|
||||
(select_input("status", "Status", &[
|
||||
("", "Any"), ("queued", "Queued"), ("running", "Running"),
|
||||
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
|
||||
("succeeded", "Succeeded"), ("cancelled", "Canceled"),
|
||||
("deadletter", "Dead-letter"),
|
||||
], p.status_filter))
|
||||
div class="flex flex-col gap-2" {
|
||||
@@ -69,8 +70,8 @@ pub struct JobsListParams<'a> {
|
||||
pub fn jobs_list_page(config: &AdminConfig, auth: &AuthContext, params: &JobsListParams) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let count_label = format!(
|
||||
"{} jobs{}",
|
||||
params.jobs.len(),
|
||||
"{}{}",
|
||||
count_noun(params.jobs.len() as u64, "job", "jobs"),
|
||||
if params.next_cursor.is_some() {
|
||||
" (more available)"
|
||||
} else {
|
||||
|
||||
@@ -4,18 +4,28 @@ use crate::templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
table::{data_table, table_cell, table_row},
|
||||
};
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub(crate) 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,
|
||||
let (label, variant) = match status {
|
||||
"queued" => ("Queued", BadgeVariant::Default),
|
||||
"running" => ("Running", BadgeVariant::Info),
|
||||
"succeeded" => ("Succeeded", BadgeVariant::Success),
|
||||
"failed" => ("Failed", BadgeVariant::Danger),
|
||||
"deadletter" => ("Dead-letter", BadgeVariant::Danger),
|
||||
"cancelled" => ("Canceled", BadgeVariant::Warning),
|
||||
other => (other, BadgeVariant::Default),
|
||||
};
|
||||
badge(status, variant)
|
||||
badge(label, variant)
|
||||
}
|
||||
|
||||
pub(crate) fn job_time(value: &str) -> String {
|
||||
match value {
|
||||
"" | "\u{2014}" => "\u{2014}".to_owned(),
|
||||
"immediate" => "Immediate".to_owned(),
|
||||
other => format_admin_timestamp(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_progress(job: &serde_json::Value) -> String {
|
||||
@@ -82,7 +92,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||
|
||||
table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="whitespace-nowrap text-sm" { (created_at) }
|
||||
span class="whitespace-nowrap text-sm" { (job_time(created_at)) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
a href={(base) "/jobs/" (job_id)}
|
||||
@@ -186,3 +196,22 @@ pub(crate) fn next_page_link(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::status_badge;
|
||||
|
||||
#[test]
|
||||
fn status_badges_use_us_spelling_and_readable_labels() {
|
||||
let canceled = status_badge("cancelled").into_string();
|
||||
assert!(canceled.contains(">Canceled<"));
|
||||
assert!(!canceled.contains("Cancelled"));
|
||||
assert!(
|
||||
status_badge("deadletter")
|
||||
.into_string()
|
||||
.contains(">Dead-letter<")
|
||||
);
|
||||
assert!(status_badge("running").into_string().contains(">Running<"));
|
||||
assert!(status_badge("mystery").into_string().contains(">mystery<"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ pub mod profile_substring_bans;
|
||||
pub mod report_detail;
|
||||
pub mod reports_list;
|
||||
pub mod search_index;
|
||||
pub mod strange_place;
|
||||
pub mod system_dm;
|
||||
pub mod url_domain_bans;
|
||||
pub mod user_detail;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, SearchReportsResponse},
|
||||
api::types::{ReportEntry, ReportReasonEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
@@ -11,11 +11,16 @@ use crate::{
|
||||
form::{secondary_button_link, select_input, submit_button, text_input},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::page_header_with_actions,
|
||||
table::{data_table, empty_state},
|
||||
report_category::{reason_repeats_category, report_category, report_category_options},
|
||||
report_webhook::{
|
||||
ReportedWebhook, reported_webhook, webhook_creator_badges, webhook_identity,
|
||||
},
|
||||
table::{empty_state, table, table_body, table_container, table_head},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::user_detail_tabs::reports::tagged_user,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{plural::count_noun, timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
@@ -24,8 +29,10 @@ pub struct ReportFilters<'a> {
|
||||
pub status: Option<&'a str>,
|
||||
pub report_type: Option<&'a str>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_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>,
|
||||
@@ -38,6 +45,7 @@ pub fn reports_list_page(
|
||||
auth: &AuthContext,
|
||||
result: Option<&SearchReportsResponse>,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
page: u32,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
@@ -48,7 +56,7 @@ pub fn reports_list_page(
|
||||
None,
|
||||
report_count_summary(result),
|
||||
))
|
||||
(filters_card(config, filters, limit))
|
||||
(filters_card(config, filters, reasons, limit))
|
||||
@if let Some(result) = result {
|
||||
@if result.reports.is_empty() {
|
||||
(empty_state("No reports found."))
|
||||
@@ -72,14 +80,25 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
|
||||
html! {
|
||||
@if let Some(result) = result {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Found " (result.total) " results (showing " (result.reports.len()) ")"
|
||||
"Found " (count_noun(result.total, "result", "results"))
|
||||
" (showing " (result.reports.len()) ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -> Markup {
|
||||
fn filters_card(
|
||||
config: &AdminConfig,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
let limit_value = limit.to_string();
|
||||
let select_grid_class = if reasons.is_some() {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
} else {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
|
||||
};
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-6 transition-all" {
|
||||
form method="get" {
|
||||
@@ -90,7 +109,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
filters.query.unwrap_or(""),
|
||||
"Search by ID, reporter, category, or description...",
|
||||
))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5" {
|
||||
div class=(select_grid_class) {
|
||||
(select_input("status", "Status", &[
|
||||
("", "All"),
|
||||
("0", "Pending"),
|
||||
@@ -102,7 +121,12 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
("1", "User"),
|
||||
("2", "Guild"),
|
||||
], filters.report_type.unwrap_or("")))
|
||||
(select_input("category", "Category", report_category_options(), filters.category.unwrap_or("")))
|
||||
(select_input("category", "Category", &report_category_options(filters.category.unwrap_or("")), filters.category.unwrap_or("")))
|
||||
@if let Some(reasons) = reasons {
|
||||
div class="sm:col-span-2 lg:col-span-2" {
|
||||
(reason_select(reasons, filters.reason.unwrap_or("")))
|
||||
}
|
||||
}
|
||||
(select_input("sort", "Sort", &[
|
||||
("reportedAt_desc", "Reported (newest first)"),
|
||||
("reportedAt_asc", "Reported (oldest first)"),
|
||||
@@ -121,6 +145,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
|
||||
(text_input("reporter_id", "Reporter user ID", filters.reporter_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_user_id", "Reported user ID", filters.reported_user_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_webhook_id", "Reported webhook ID", filters.reported_webhook_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_guild_id", "Reported guild ID", filters.reported_guild_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_channel_id", "Reported channel ID", filters.reported_channel_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("guild_context_id", "Guild context ID", filters.guild_context_id.unwrap_or(""), "Snowflake"))
|
||||
@@ -136,28 +161,29 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
}
|
||||
}
|
||||
|
||||
fn report_category_options() -> &'static [(&'static str, &'static str)] {
|
||||
&[
|
||||
("", "All"),
|
||||
("harassment", "Harassment or Bullying"),
|
||||
("hate_speech", "Hate Speech"),
|
||||
("spam", "Spam or Scam"),
|
||||
("illegal_activity", "Illegal Activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child Safety Concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or Graphic Content"),
|
||||
("nsfw_violation", "NSFW Policy Violation"),
|
||||
("doxxing", "Sharing Personal Information"),
|
||||
("self_harm", "Self-Harm or Suicide"),
|
||||
("malicious_links", "Malicious Links"),
|
||||
("spam_account", "Spam Account"),
|
||||
("underage_user", "Underage User"),
|
||||
("inappropriate_profile", "Inappropriate Profile"),
|
||||
("raid_coordination", "Raid Coordination"),
|
||||
("malware_distribution", "Malware Distribution"),
|
||||
("extremist_community", "Extremist Community"),
|
||||
]
|
||||
fn reason_select(reasons: &[ReportReasonEntry], selected: &str) -> Markup {
|
||||
let labels = reasons
|
||||
.iter()
|
||||
.map(|reason| {
|
||||
if reason.highest_priority {
|
||||
format!("Priority: {}", reason.label)
|
||||
} else {
|
||||
reason.label.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(
|
||||
reasons
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|(reason, label)| (reason.key.as_str(), label.as_str())),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
select_input("reason", "Reason", &options, selected)
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
@@ -177,36 +203,21 @@ fn format_report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_category(category: Option<&str>) -> String {
|
||||
let Some(category) = category else {
|
||||
return "\u{2014}".to_owned();
|
||||
};
|
||||
report_category_options()
|
||||
.iter()
|
||||
.find_map(|(value, label)| (*value == category).then_some((*label).to_owned()))
|
||||
.unwrap_or_else(|| category.to_owned())
|
||||
}
|
||||
|
||||
fn reporter_label(report: &ReportEntry) -> String {
|
||||
fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(username) = &report.reporter_username {
|
||||
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(display) = report
|
||||
.reporter_global_name
|
||||
.as_ref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
{
|
||||
return format!("{display} ({tag})");
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(tag) = &report.reporter_tag {
|
||||
return tag.to_owned();
|
||||
html! {
|
||||
(report
|
||||
.reporter_tag
|
||||
.as_deref()
|
||||
.or(report.reporter_email.as_deref())
|
||||
.unwrap_or("Anonymous"))
|
||||
}
|
||||
if let Some(email) = &report.reporter_email {
|
||||
return email.to_owned();
|
||||
}
|
||||
"Anonymous".to_owned()
|
||||
}
|
||||
|
||||
fn reported_user_label(report: &ReportEntry) -> String {
|
||||
@@ -277,14 +288,30 @@ fn reported_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_name(report: &ReportEntry) -> Markup {
|
||||
match &report.reported_user_username {
|
||||
Some(username) => tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
),
|
||||
None => html! { (reported_user_label(report)) },
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let primary = reported_user_label(report);
|
||||
html! {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (primary) }
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (reported_user_name(report)) }
|
||||
}
|
||||
@if report.reported_user_bot == Some(true) {
|
||||
span class="inline-flex" data-report-user-bot=(id) { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (primary) }
|
||||
@@ -328,7 +355,7 @@ fn reported_guild_icon(
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
@@ -340,26 +367,28 @@ fn reported_guild_icon(
|
||||
|
||||
fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let user_label = reported_user_label(report);
|
||||
let channel_label = report
|
||||
.reported_channel_name
|
||||
.as_deref()
|
||||
.or(report.reported_channel_id.as_deref())
|
||||
.unwrap_or("Unknown channel");
|
||||
let channel_prefix =
|
||||
if report.reported_guild_id.is_none() && report.reported_channel_id.is_some() {
|
||||
"DM channel: "
|
||||
} else {
|
||||
"Channel: "
|
||||
};
|
||||
html! {
|
||||
div class="flex flex-col gap-1" {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &user_label))
|
||||
span class="font-medium text-sm" { (user_label) }
|
||||
}
|
||||
@if let Some(webhook) = reported_webhook(config, report) {
|
||||
(reported_webhook_author(&webhook))
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (user_label) }
|
||||
(reported_user_cell(config, report))
|
||||
}
|
||||
@if let Some(channel_id) = &report.reported_channel_id {
|
||||
a href=(message_lookup_href(base, channel_id, report.reported_message_id.as_deref()))
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Channel: " (channel_label)
|
||||
(channel_prefix) (channel_label)
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500 text-xs" { "Channel: " (channel_label) }
|
||||
@@ -371,6 +400,27 @@ fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col items-start gap-0.5" {
|
||||
(webhook_identity(webhook))
|
||||
a href=(webhook.reports_href) title="Reports about this webhook"
|
||||
class="font-mono text-blue-600 text-xs [overflow-wrap:normal] hover:underline" {
|
||||
"Webhook ID: " (webhook.id)
|
||||
}
|
||||
@if let Some(ref creator) = webhook.creator {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Webhook creator: " (creator.label)
|
||||
}
|
||||
@if creator.bot || creator.account_deleted {
|
||||
span class="inline-flex flex-wrap items-center gap-1" { (webhook_creator_badges(creator)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_avatar(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
@@ -385,7 +435,7 @@ fn reported_user_avatar(
|
||||
true,
|
||||
);
|
||||
html! {
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 rounded-full object-cover";
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 max-w-none rounded-full object-cover";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,32 +452,60 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
let rows = html! {
|
||||
@for report in reports {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-neutral-600 text-sm" {
|
||||
(report.reported_at)
|
||||
td class="hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell" {
|
||||
(format_admin_timestamp(&report.reported_at))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
|
||||
(format_admin_timestamp(&report.reported_at))
|
||||
}
|
||||
@let (type_label, type_variant) = format_report_type(report.report_type);
|
||||
(badge(type_label, type_variant))
|
||||
span class="text-neutral-600 text-xs" {
|
||||
(format_category(report.category.as_deref()))
|
||||
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
@let repeats = report.category.as_deref().zip(reason_label).is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
@let priority = report.reason_highest_priority == Some(true);
|
||||
@let category_class = if repeats && priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-600 text-xs"
|
||||
};
|
||||
span class=(category_class) data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
@if let Some(category) = &report.category {
|
||||
(report_category(category))
|
||||
} @else {
|
||||
"\u{2014}"
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
@let reason_class = if priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-500 text-xs"
|
||||
};
|
||||
span class=(reason_class) data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
|
||||
(reporter_cell(config, report))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
|
||||
(reported_cell(config, report))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
@let (label, variant) = format_status(report.status);
|
||||
@let (status_label, status_variant) = format_status(report.status);
|
||||
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
|
||||
span data-status-pill=(report.report_id) {
|
||||
(badge(label, variant))
|
||||
(badge(status_label, status_variant))
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
span class="xl:hidden" data-status-pill-compact=(report.report_id) {
|
||||
(badge(status_label, status_variant))
|
||||
}
|
||||
button type="button"
|
||||
data-drawer-open="report-peek"
|
||||
data-drawer-href={(base) "/reports/" (report.report_id) "/fragment"}
|
||||
@@ -455,7 +533,25 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
};
|
||||
html! {
|
||||
div data-report-table="true" {
|
||||
(data_table(headers, rows))
|
||||
(table_container(table(html! {
|
||||
(table_head(html! {
|
||||
tr {
|
||||
@for (index, header) in headers.iter().enumerate() {
|
||||
th class={"px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" (match index {
|
||||
0 | 4 => " hidden whitespace-nowrap xl:table-cell",
|
||||
5 => " xl:whitespace-nowrap",
|
||||
_ => " whitespace-nowrap",
|
||||
})} {
|
||||
@if index == 5 {
|
||||
span class="xl:hidden" { "Status / " }
|
||||
}
|
||||
(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
(table_body(rows))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,8 +604,14 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
push_param(&mut params, "status", filters.status);
|
||||
push_param(&mut params, "type", filters.report_type);
|
||||
push_param(&mut params, "category", filters.category);
|
||||
push_param(&mut params, "reason", filters.reason);
|
||||
push_param(&mut params, "reporter_id", filters.reporter_id);
|
||||
push_param(&mut params, "reported_user_id", filters.reported_user_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
"reported_webhook_id",
|
||||
filters.reported_webhook_id,
|
||||
);
|
||||
push_param(&mut params, "reported_guild_id", filters.reported_guild_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
@@ -538,3 +640,126 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
.join("&");
|
||||
format!("{}/reports?{}", config.base_path, query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> ReportEntry {
|
||||
let mut value = json!({
|
||||
"report_id": "1556709309709027556",
|
||||
"reporter_id": "1556709306000000001",
|
||||
"reporter_username": "reporter_80f33d09e88a",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "5193",
|
||||
"reported_at": "2026-10-06T04:33:00Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1556709306000000002",
|
||||
"reported_user_username": "target_bc57b33ca5c4",
|
||||
"reported_user_discriminator": "8316"
|
||||
});
|
||||
for key in [
|
||||
"reporter_tag",
|
||||
"reporter_email",
|
||||
"reporter_full_legal_name",
|
||||
"reporter_country_of_residence",
|
||||
"additional_info",
|
||||
"reported_user_tag",
|
||||
"reported_user_global_name",
|
||||
"reported_user_avatar_hash",
|
||||
"reported_guild_id",
|
||||
"reported_guild_name",
|
||||
"reported_guild_icon_hash",
|
||||
"reported_message_id",
|
||||
"reported_channel_id",
|
||||
"reported_channel_name",
|
||||
"reported_channel_nsfw",
|
||||
"reported_guild_invite_code",
|
||||
"reported_guild_nsfw_level",
|
||||
"reported_guild_nsfw",
|
||||
"reported_guild_content_warning_level",
|
||||
"reported_guild_content_warning_text",
|
||||
"reported_channel_nsfw_override",
|
||||
"reported_channel_content_warning_level",
|
||||
"reported_channel_content_warning_text",
|
||||
"reported_channel_effective_nsfw",
|
||||
"reported_channel_effective_content_warning_level",
|
||||
"reported_channel_effective_content_warning_text",
|
||||
"resolved_at",
|
||||
"resolved_by_admin_id",
|
||||
"public_comment",
|
||||
"mutual_dm_channel_id",
|
||||
"message_context",
|
||||
] {
|
||||
value[key] = serde_json::Value::Null;
|
||||
}
|
||||
serde_json::from_value(value).expect("report fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_layout_moves_date_and_status_into_visible_columns() {
|
||||
let markup = render_reports_table(&test_config(), &[report()]).into_string();
|
||||
assert!(markup.contains(
|
||||
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
|
||||
));
|
||||
assert!(markup.contains(
|
||||
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
|
||||
));
|
||||
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap xl:table-cell")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(
|
||||
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches("[overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::noun_for;
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -139,7 +140,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
|
||||
@if status_str == "completed" {
|
||||
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Indexed " (idx) " / " (tot) " items"
|
||||
"Indexed " (idx) " / " (tot) " " (noun_for(tot, "item", "items"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{config::AdminConfig, middleware::auth::AuthContext, templates::layout::admin_layout};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn strange_place_page(config: &AdminConfig, auth: &AuthContext) -> Markup {
|
||||
let content = html! {
|
||||
div class="mx-auto max-w-2xl" {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-8 shadow-sm" {
|
||||
div class="space-y-4 text-center" {
|
||||
div class="mx-auto flex h-16 w-16 items-center justify-center \
|
||||
rounded-full bg-neutral-100" {
|
||||
span class="text-lg text-neutral-400" { "?" }
|
||||
}
|
||||
h2 class="text-lg font-semibold text-neutral-900" {
|
||||
"You've reached a strange place"
|
||||
}
|
||||
p class="text-sm text-neutral-600" {
|
||||
"You don't have access to any admin features. "
|
||||
"Contact an administrator to request access."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
admin_layout(config, auth, "Strange Place", "", None, content)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
|
||||
},
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -160,7 +161,7 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
|
||||
}))
|
||||
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().map(format_admin_timestamp).unwrap_or_default()) }))
|
||||
(table_cell(false, html! {
|
||||
form method="post" action=(action_url) {
|
||||
(csrf_input(csrf_token))
|
||||
|
||||
@@ -33,26 +33,6 @@ pub const USER_TABS: &[(&str, &str)] = &[
|
||||
("audit_logs", "Audit Logs"),
|
||||
];
|
||||
|
||||
pub fn user_detail_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
user: Option<&AdminUser>,
|
||||
user_id: &str,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
user_detail_with_tab(
|
||||
config,
|
||||
auth,
|
||||
user,
|
||||
user_id,
|
||||
"overview",
|
||||
None,
|
||||
premium_badge_name,
|
||||
is_htmx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn user_detail_with_tab(
|
||||
config: &AdminConfig,
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn account_tab(
|
||||
(sessions_card(config, sessions))
|
||||
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
|
||||
(clear_fields_card(base, user, csrf_token))
|
||||
(user_status_card(base, user, csrf_token))
|
||||
(security_actions_card(base, user, csrf_token))
|
||||
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
|
||||
}
|
||||
@@ -215,11 +216,11 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
|
||||
span class="ml-2 inline-flex items-center rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600" { "Terminated" }
|
||||
}
|
||||
}))
|
||||
(meta_cell("Created", html! { (s.created_at) }))
|
||||
(meta_cell("Created", html! { (format_admin_timestamp(&s.created_at)) }))
|
||||
@if let Some(ref d) = s.deleted_at {
|
||||
(meta_cell("Terminated", html! { (d) }))
|
||||
(meta_cell("Terminated", html! { (format_admin_timestamp(d)) }))
|
||||
} @else {
|
||||
(meta_cell("Last Used", html! { (s.approx_last_used_at) }))
|
||||
(meta_cell("Last Used", html! { (format_admin_timestamp(&s.approx_last_used_at)) }))
|
||||
}
|
||||
div class="md:col-span-3" {
|
||||
(meta_cell("IP Address", html! {
|
||||
@@ -310,6 +311,19 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
let is_bot = user.bot;
|
||||
let is_sys = user.system;
|
||||
html! {
|
||||
(card_with_header("User Status", html! {
|
||||
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
|
||||
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
|
||||
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Security Actions", html! {
|
||||
@@ -374,11 +388,11 @@ fn webauthn_credential_row(
|
||||
p class="text-sm text-neutral-900" { (credential.name) }
|
||||
}
|
||||
td class="py-2 pr-4" {
|
||||
p class="text-sm text-neutral-900" { (credential.created_at) }
|
||||
p class="text-sm text-neutral-900" { (format_admin_timestamp(&credential.created_at)) }
|
||||
}
|
||||
td class="py-2 pr-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
(credential.last_used_at.as_deref().unwrap_or("Never"))
|
||||
(credential.last_used_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
|
||||
}
|
||||
}
|
||||
td class="py-2" {
|
||||
@@ -450,3 +464,40 @@ fn action_form(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_toggle(
|
||||
base: &str,
|
||||
uid: &str,
|
||||
action: &str,
|
||||
active: bool,
|
||||
kind: &str,
|
||||
csrf: &str,
|
||||
) -> Markup {
|
||||
let status_val = if active { "false" } else { "true" };
|
||||
let label = format!(
|
||||
"{} {} Status",
|
||||
if active { "Remove" } else { "Set" },
|
||||
capitalize(kind)
|
||||
);
|
||||
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
|
||||
html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
hx-target="#flash-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false" {
|
||||
(csrf_input(csrf))
|
||||
input type="hidden" name=(kind) value=(status_val);
|
||||
button type="submit" class=(BTN_CLS) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().chain(c).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::DmChannel, config::AdminConfig,
|
||||
templates::components::page_container::card_with_header,
|
||||
@@ -85,7 +86,7 @@ fn group_dm_card(base: &str, user_id: &str, channel: &DmChannel) -> Markup {
|
||||
}
|
||||
div class="space-y-1" {
|
||||
p class="text-sm text-neutral-500" {
|
||||
(channel.recipients.len()) " recipients \u{00b7} Status: "
|
||||
(count_noun(channel.recipients.len() as u64, "recipient", "recipients")) " \u{00b7} Status: "
|
||||
@if channel.is_open { "Open" } @else { "Closed" }
|
||||
}
|
||||
@if let Some(o) = owner {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::GuildInfo,
|
||||
config::AdminConfig,
|
||||
@@ -63,7 +64,7 @@ fn guild_row(config: &AdminConfig, user_id: &str, guild: &GuildInfo) -> Markup {
|
||||
}
|
||||
}
|
||||
span class="text-xs text-neutral-500" {
|
||||
(guild.member_count) " members"
|
||||
(count_noun(guild.member_count, "member", "members"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ pub mod moderation;
|
||||
pub mod overview;
|
||||
pub mod relationships;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
|
||||
pub(super) fn resolved_user_display(user: &AdminResolvedUser) -> String {
|
||||
let tag = user_tag(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::{count_noun, noun_for};
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
@@ -359,13 +360,13 @@ fn deletion_card(
|
||||
div class="space-y-3" {
|
||||
(form_label("Private Reason"))
|
||||
input type="text" name="private_reason" required
|
||||
placeholder="Why this deletion is being cancelled (audit log)..."
|
||||
placeholder="Why this deletion is being canceled (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
@if !username_sign_in {
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
|
||||
}
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
@@ -567,7 +568,7 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
|
||||
"Status: " (message_shred_status_label(status_value))
|
||||
}
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Requested " (requested) " entries, skipped " (skipped) " entries"
|
||||
"Requested " (count_noun(requested, "entry", "entries")) ", skipped " (count_noun(skipped, "entry", "entries"))
|
||||
}
|
||||
@if status_value == "in_progress" {
|
||||
div class="space-y-2" {
|
||||
@@ -582,17 +583,17 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
|
||||
}
|
||||
@if status_value == "completed" {
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Deleted " (processed) " / " (total) " entries"
|
||||
"Deleted " (processed) " / " (total) " " (noun_for(total, "entry", "entries"))
|
||||
}
|
||||
}
|
||||
@if let Some(started_at) = value_str(status, "started_at") {
|
||||
p class="text-xs text-neutral-500" { "Started " (started_at) }
|
||||
p class="text-xs text-neutral-500" { "Started " (format_admin_timestamp(started_at)) }
|
||||
}
|
||||
@if let Some(completed_at) = value_str(status, "completed_at") {
|
||||
p class="text-xs text-neutral-500" { "Completed " (completed_at) }
|
||||
p class="text-xs text-neutral-500" { "Completed " (format_admin_timestamp(completed_at)) }
|
||||
}
|
||||
@if let Some(failed_at) = value_str(status, "failed_at") {
|
||||
p class="text-xs text-red-600" { "Failed " (failed_at) }
|
||||
p class="text-xs text-red-600" { "Failed " (format_admin_timestamp(failed_at)) }
|
||||
}
|
||||
@if let Some(error) = value_str(status, "error") {
|
||||
p class="text-sm text-red-600" { (error) }
|
||||
|
||||
@@ -66,13 +66,13 @@ fn render_overview_tab(
|
||||
div class="space-y-6" {
|
||||
@if let Some(until) = &user.temp_banned_until {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
|
||||
"Temporarily banned until " (until)
|
||||
"Temporarily banned until " (format_admin_timestamp(until))
|
||||
}
|
||||
}
|
||||
@if user.temp_banned_until.is_none() {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
|
||||
div class="font-medium" { "Scheduled for deletion: " (pending) }
|
||||
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
|
||||
@if let Some(code) = user.deletion_reason_code {
|
||||
div class="mt-1" { "Reason code: " (code) }
|
||||
}
|
||||
@@ -101,7 +101,7 @@ fn render_overview_tab(
|
||||
@if let Some(pending) = &user.pending_bulk_message_deletion_at {
|
||||
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
div class="font-medium text-neutral-700 text-sm" {
|
||||
"Bulk message deletion scheduled for " (pending)
|
||||
"Bulk message deletion scheduled for " (format_admin_timestamp(pending))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::USER_CANCEL_BULK_MESSAGE_DELETION) {
|
||||
form method="post"
|
||||
@@ -197,10 +197,10 @@ fn render_overview_tab(
|
||||
}
|
||||
}))
|
||||
@if let Some(ref value) = user.premium_grace_ends_at {
|
||||
(detail_row("Grace Period Ends", html! { (value) }))
|
||||
(detail_row("Grace Period Ends", html! { (format_admin_timestamp(value)) }))
|
||||
}
|
||||
(detail_row("Last Active", html! {
|
||||
(user.last_active_at.as_deref().unwrap_or("Never"))
|
||||
(user.last_active_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
|
||||
}))
|
||||
@if acl::has_permission(admin_acls, acl::USER_VIEW_IP) {
|
||||
(detail_row("Last IP", html! {
|
||||
@@ -239,7 +239,7 @@ fn render_overview_tab(
|
||||
div class="py-3 text-sm" {
|
||||
dt class="font-medium text-neutral-900" {
|
||||
(entry.field)
|
||||
span class="ml-2 font-normal text-xs text-neutral-500" { (entry.event_at) }
|
||||
span class="ml-2 font-normal text-xs text-neutral-500" { (format_admin_timestamp(&entry.event_at)) }
|
||||
}
|
||||
dd class="mt-1 font-mono text-xs text-neutral-700" {
|
||||
(entry.old_value.as_deref().unwrap_or("null"))
|
||||
@@ -437,6 +437,11 @@ fn acls_card(
|
||||
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
|
||||
}
|
||||
}
|
||||
@for item in &user.acls {
|
||||
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
|
||||
input type="hidden" name="acls[]" value=(item);
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ACLs"))
|
||||
}))
|
||||
@@ -594,6 +599,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
@@ -639,4 +645,23 @@ mod tests {
|
||||
fn email_mode_shows_the_email_row() {
|
||||
assert!(render_email_row(false).contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_active_and_grace_end_use_the_panel_date_format() {
|
||||
let user: AdminUser = serde_json::from_value(serde_json::json!({
|
||||
"id": "1500000000000000001",
|
||||
"username": "target",
|
||||
"discriminator": "0001",
|
||||
"last_active_at": "2026-10-06T20:23:38.591Z",
|
||||
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
|
||||
}))
|
||||
.expect("valid admin user");
|
||||
let html =
|
||||
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
|
||||
.into_string();
|
||||
assert!(!html.contains("2026-10-06T20:23"));
|
||||
assert!(!html.contains("2026-11-01T08:00"));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
|
||||
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{ListUserRelationshipsResponse, RelationshipEntry},
|
||||
config::AdminConfig,
|
||||
@@ -176,7 +177,7 @@ fn relationship_row(
|
||||
}
|
||||
@if let Some(ref since) = entry.since {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Since: " (since)
|
||||
"Since: " (format_admin_timestamp(since))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use crate::{
|
||||
templates::components::{
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::card_with_header,
|
||||
table::data_table,
|
||||
report_category::{reason_repeats_category, report_category},
|
||||
report_webhook::{reported_webhook, webhook_identity},
|
||||
table::{table, table_body, table_container, table_head},
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -102,13 +104,11 @@ fn report_section(
|
||||
p class="text-sm text-neutral-500" { (empty_msg) }
|
||||
} @else {
|
||||
@let headers = if kind == "sent" {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reported Entity", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reported Entity", "Status"]
|
||||
} else {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reporter", "Status"]
|
||||
};
|
||||
(data_table(
|
||||
(report_tab_table(
|
||||
&headers,
|
||||
html! {
|
||||
@for report in reports {
|
||||
@@ -152,7 +152,7 @@ fn format_report_type(report_type: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
pub(crate) fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
@@ -160,36 +160,117 @@ fn format_status(status: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
pub(crate) fn tagged_user(
|
||||
global_name: Option<&str>,
|
||||
username: &str,
|
||||
discriminator: Option<&str>,
|
||||
) -> Markup {
|
||||
let tag = user_tag(username, discriminator.unwrap_or("0000"), false);
|
||||
html! {
|
||||
@if let Some(name) = global_name.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
(name) " "
|
||||
span class="whitespace-nowrap text-neutral-500 text-xs" { "(" (tag) ")" }
|
||||
} @else {
|
||||
span class="whitespace-nowrap" { (tag) }
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
html! {
|
||||
(report
|
||||
.reporter_email
|
||||
.as_deref()
|
||||
.or(report.reporter_id.as_deref())
|
||||
.unwrap_or("Unknown reporter"))
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
}
|
||||
|
||||
pub(crate) fn report_tab_table(headers: &[&str], rows: Markup) -> Markup {
|
||||
let has_actions = headers.last() == Some(&"Actions");
|
||||
html! {
|
||||
(table_container(table(html! {
|
||||
(table_head(html! {
|
||||
tr {
|
||||
@for (index, header) in headers.iter().enumerate() {
|
||||
@let folded = index == 1 || (has_actions && *header == "Status");
|
||||
th class={"px-2 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider xl:px-4" (if folded { " hidden whitespace-nowrap xl:table-cell" } else { " whitespace-nowrap" })} {
|
||||
@if has_actions && index + 1 == headers.len() {
|
||||
span class="xl:hidden" { "Status / " }
|
||||
}
|
||||
(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
(table_body(rows))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
|
||||
html! {
|
||||
td class="px-2 py-3 text-sm text-neutral-900 xl:px-3" {
|
||||
div { (format_admin_timestamp(&report.reported_at)) }
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
div class="mt-1 xl:hidden" data-report-type-compact=(report.report_id) {
|
||||
(type_and_category_lines(report))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
|
||||
html! {
|
||||
td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
|
||||
(type_and_category_lines(report))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_and_category_lines(report: &ReportEntry) -> Markup {
|
||||
let reason_label = report
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
.map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
let repeats = report
|
||||
.category
|
||||
.as_deref()
|
||||
.zip(reason_label)
|
||||
.is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
html! {
|
||||
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
|
||||
@if let Some(category) = &report.category {
|
||||
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
(report_category(category))
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry) -> Markup {
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
let webhook = if kind == "sent" {
|
||||
reported_webhook(config, report)
|
||||
} else {
|
||||
format_reporter(report)
|
||||
None
|
||||
};
|
||||
|
||||
let entity_href = if kind == "sent" {
|
||||
@@ -210,81 +291,62 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
.map(|id| format!("{base}/users/{id}"))
|
||||
};
|
||||
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
} else {
|
||||
reporter_label(report)
|
||||
};
|
||||
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
@if let Some(href) = entity_href {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
|
||||
@if let Some(webhook) = &webhook {
|
||||
a href=(webhook.reports_href) class="hover:underline" {
|
||||
(webhook_identity(webhook))
|
||||
}
|
||||
} @else if let Some(href) = entity_href {
|
||||
a href=(href)
|
||||
class="inline-flex items-center gap-2 hover:underline" {
|
||||
@if kind == "sent" {
|
||||
(reported_entity_icon(config, report, &entity_display))
|
||||
(reported_entity_icon(config, report))
|
||||
}
|
||||
span { (entity_display) }
|
||||
span class="min-w-0" { (entity_display) }
|
||||
}
|
||||
} @else {
|
||||
span { (entity_display) }
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
|
||||
(format_status(report.status))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-1.5 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reported_entity(report: &ReportEntry) -> String {
|
||||
fn format_reported_entity(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reported_user_username {
|
||||
let disc = report
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reported_user_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reported_user_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref id) = report.reported_user_id {
|
||||
return id.clone();
|
||||
html! {
|
||||
(report
|
||||
.reported_user_id
|
||||
.as_ref()
|
||||
.or(report.reported_guild_name.as_ref())
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.map_or("Unknown", String::as_str))
|
||||
}
|
||||
report
|
||||
.reported_guild_name
|
||||
.as_ref()
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_owned())
|
||||
}
|
||||
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str) -> Markup {
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
if let Some(ref user_id) = report.reported_user_id {
|
||||
return html! {
|
||||
img
|
||||
@@ -307,9 +369,174 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str)
|
||||
}
|
||||
return html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
(initials(report.reported_guild_name.as_deref().unwrap_or(guild_id)))
|
||||
}
|
||||
};
|
||||
}
|
||||
html! {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1700000000000000500",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_guild_id": "1700000000000000800",
|
||||
"reported_guild_name": "Harbor"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let config = test_config();
|
||||
let sent = report_row(&config, "/admin", "sent", &report).into_string();
|
||||
assert!(
|
||||
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains(r#"data-report-webhook="1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(sent.contains("Harbor Bulletin"), "{sent}");
|
||||
assert!(!sent.contains("/admin/guilds/"), "{sent}");
|
||||
let received = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert!(!received.contains("data-report-webhook"), "{received}");
|
||||
assert!(
|
||||
received.contains(r#"href="/admin/users/1500000000000000000""#),
|
||||
"{received}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_label_the_category_and_keep_the_key() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000005",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "inappropriate_profile",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
for kind in ["sent", "received"] {
|
||||
let markup = report_row(&config, "/admin", kind, &report).into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="inappropriate_profile""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
|
||||
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-reason="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(r#"href="/admin/reports/1800000000000000005""#)
|
||||
.count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(!markup.contains(">View<"), "{markup}");
|
||||
}
|
||||
let mut unknown = report.clone();
|
||||
unknown.category = Some("future_value".to_owned());
|
||||
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
|
||||
assert!(markup.contains(">future_value<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_skip_a_reason_that_repeats_the_category() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000007",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert_eq!(
|
||||
markup.matches("Harassment or bullying").count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup.matches(r#"data-report-reason="harassment""#).count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(
|
||||
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
|
||||
),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Message<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user