Compare commits

..
Author SHA1 Message Date
HampusandGitHub ec681e6061 fix(desktop): retry an update that never installed (#3354) 2026-10-09 15:03:36 +02:00
HampusandGitHub 0fc6fad11d fix(ui): report the shown carousel step so report flows react (#3353) 2026-10-09 14:53:08 +02:00
HampusandGitHub eb329f2cd0 fix(app): add missing data-flx attributes (#3351) 2026-10-09 14:18:01 +02:00
HampusandGitHub 9a20821332 feat(app,api): let group DM owners allow mature content (#3350) 2026-10-09 14:12:13 +02:00
HampusandGitHub 4749eb7f86 ci(desktop): use a clean profile for each Windows update check (#3349) 2026-10-09 08:32:45 +02:00
HampusandGitHub 2d83fd2a93 ci(desktop): keep the logs from Windows update checks (#3348) 2026-10-09 08:22:21 +02:00
HampusandGitHub d180af6ba2 fix(desktop): clean up release check runs on Windows (#3347) 2026-10-09 06:53:50 +02:00
HampusandGitHub 4810eb3e24 feat(ci): run the release check on built Windows shells (#3346) 2026-10-09 05:40:01 +02:00
HampusandGitHub 48a19dedd8 fix(desktop): stop requiring the removed win-toast at startup (#3345) 2026-10-09 05:39:56 +02:00
HampusandGitHub cadf7ea532 feat(schema): add double tap action to synced preferences (#3344) 2026-10-09 04:09:24 +02:00
HampusandGitHub fc60d05f17 feat(app): load heavy desktop assets on demand, update in place (#3343) 2026-10-09 03:53:57 +02:00
HampusandGitHub 5072488f1e feat(desktop): prefer the bundled renderer, own update prompts (#3341) 2026-10-09 03:01:15 +02:00
HampusandGitHub 7a5b9a0ded docs(threads): publish threads, forums and media channels (#3342) 2026-10-09 02:44:06 +02:00
HampusandGitHub 4ce4d4e09a feat(ci): bundle the renderer in the desktop shell (#3339) 2026-10-09 02:31:37 +02:00
HampusandGitHub 82abd03a37 feat(reports): delete reports and configure retention (#3338) 2026-10-09 01:41:47 +02:00
HampusandGitHub 93d1f8af46 refactor: remove dead code (#3337) 2026-10-09 01:41:26 +02:00
HampusandGitHub a19002652b docs: use British English throughout (#3336) 2026-10-09 01:40:55 +02:00
HampusandGitHub 6534ee4300 feat(admin): require a resolution and fix panel layout and copy (#3335) 2026-10-09 01:40:34 +02:00
HampusandGitHub 4db13b0375 fix(app,api): kick reasons, blocked counts and locale fallbacks (#3334) 2026-10-09 01:40:08 +02:00
HampusandGitHub a5c1362f4a fix(self-hosting): use instance contacts and live product name (#3333) 2026-10-09 01:39:35 +02:00
HampusandGitHub 454fefdb92 chore(moderation): remove the built-in NCMEC integration (#3332) 2026-10-09 01:39:04 +02:00
HampusandGitHub f390e610b9 fix(tooling): repair test, i18n and docs verification gates (#3331) 2026-10-09 01:38:34 +02:00
HampusandGitHub ece622e800 feat(reports): add server-driven report flows (#3330) 2026-10-09 01:38:00 +02:00
HampusandGitHub bc17922b02 fix(gateway): wire presence grace, request limits and voice sync (#3329) 2026-10-09 01:37:25 +02:00
HampusandGitHub a32d8e4f52 fix(i18n): use the web term for instance in id, tr and vi (#3328) 2026-10-09 01:36:57 +02:00
HampusandGitHub 12daeb758d fix(app): retry failed images at once when back online (#3327) 2026-10-08 23:39:18 +02:00
HampusandGitHub fff67ed9af fix(desktop): fetch theme CSS via the local resource proxy (#3326) 2026-10-08 23:21:27 +02:00
HampusandGitHub d0b4816a5f fix(media-proxy): allow the desktop app origin and preflight (#3325) 2026-10-08 23:21:10 +02:00
HampusandGitHub 0febad324c fix(repo): match editorconfig to each formatter (#3324) 2026-10-08 22:07:46 +02:00
HampusandGitHub dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
HampusandGitHub e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
HampusandGitHub ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
HampusandGitHub 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
HampusandGitHub acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
HampusandGitHub a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
HampusandGitHub fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
HampusandGitHub b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
HampusandGitHub 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
HampusandGitHub 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
HampusandGitHub 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
HampusandGitHub bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
JiraliteandGitHub ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
HampusandGitHub abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
HampusandGitHub 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
HampusandGitHub 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
HampusandGitHub aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
HampusandGitHub c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
HampusandGitHub 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
HampusandGitHub ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
HampusandGitHub 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
JiraliteandGitHub e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
HampusandGitHub 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
HampusandGitHub 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
HampusandGitHub 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
HampusandGitHub 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
HampusandGitHub b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
HampusandGitHub b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
HampusandGitHub 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
HampusandGitHub 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
HampusandGitHub 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
HampusandGitHub c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
HampusandGitHub 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
HampusandGitHub 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
HampusandGitHub d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
HampusandGitHub 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
HampusandGitHub 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
HampusandGitHub c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
HampusandGitHub 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
HampusandGitHub e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
HampusandGitHub 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
HampusandGitHub bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
HampusandGitHub 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
HampusandGitHub 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
HampusandGitHub f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
HampusandGitHub e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
HampusandGitHub f8505d19f7 feat(threads): ship threads and the Plutonium page to everyone (#3277) 2026-10-08 16:30:39 +02:00
HampusandGitHub 1515a8487f fix(desktop): fall back across resolved addresses on connect (#3275) 2026-10-08 05:08:00 +02:00
HampusandGitHub 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
HampusandGitHub 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
HampusandGitHub 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
HampusandGitHub d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
HampusandGitHub 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
HampusandGitHub 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
HampusandGitHub c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
HampusandGitHub e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
HampusandGitHub de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
HampusandGitHub 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
HampusandGitHub ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
HampusandGitHub 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
HampusandGitHub 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
HampusandGitHub 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
2438 changed files with 144851 additions and 153704 deletions
+27
View File
@@ -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[@]}"
+67 -1
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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",
]
-1
View File
@@ -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
-1
View File
@@ -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" },
+16 -10
View File
@@ -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
+3 -5
View File
@@ -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:-}
-1
View File
@@ -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"] }
+7 -1
View File
@@ -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");
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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,
+34
View File
@@ -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"
);
}
}
}
+3 -3
View File
@@ -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,
},
+1 -10
View File
@@ -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 {
+1 -33
View File
@@ -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,
+50 -20
View File
@@ -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,
+4 -147
View File
@@ -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!({})
);
}
}
-5
View File
@@ -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>,
}
-6
View File
@@ -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)]
+174 -5
View File
@@ -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,
+22
View File
@@ -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,
+311 -2
View File
@@ -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");
}
}
+1 -14
View File
@@ -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()))
}
+1
View File
@@ -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(),
}
}
}
+7 -3
View File
@@ -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)]
+11 -7
View File
@@ -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,
-1
View File
@@ -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;
-6
View File
@@ -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 {
+8 -1
View File
@@ -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")),
-9
View File
@@ -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,
))
}
+1 -17
View File
@@ -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(),
}
}
+4 -1
View File
@@ -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(),
+347 -50
View File
@@ -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(&params),
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"
);
}
}
-10
View File
@@ -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
+55 -170
View File
@@ -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");
+23 -5
View File
@@ -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" => {
+32 -1
View File
@@ -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 {
+6 -29
View File
@@ -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);
}
});
})();"#
+2 -2
View File
@@ -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(),
}
})
-6
View File
@@ -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" {}
}
}
+19 -21
View File
@@ -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;
+1 -1
View File
@@ -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() {
+2 -1
View File
@@ -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))))
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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<"));
}
}
-1
View File
@@ -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
+307 -82
View File
@@ -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] [&amp;_.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